From 1a6e9b17d2af945d85f15012353642b829669a93 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Mon, 15 Dec 2025 15:00:45 +0200 Subject: [PATCH] feat: Add shared components, refactor stores, improve data-entry workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared Components: - Add CompanySelector.vue and PeriodSelector.vue components - Add AppHeader.vue and SlideMenu.vue layout components - Add shared stores factories (companies.js, accountingPeriod.js) - Add shared routes factories (companies.py, calendar.py) - Add shared models (company.py, calendar.py) - Add shared layout styles (header.css, navigation.css) Data Entry App: - Update CLAUDE.md with prod/test server documentation - Improve nomenclature sync service with better error handling - Update receipts router and CRUD operations - Add company/period stores using shared factories - Update App.vue layout with shared components - Fix OCRUploadZone file handling Reports App: - Refactor stores to use shared factories - Update App.vue to use shared layout components Infrastructure: - Replace start-data-entry.sh with separate dev/test scripts - Add .claude/rules for authentication, backend patterns, etc. - Add implementation plan for OCR receipt improvements - Clean up old documentation files ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/rules/authentication.md | 17 + .claude/rules/backend-patterns.md | 44 ++ .claude/rules/company-period.md | 19 + .claude/rules/css-design-system.md | 26 + .claude/rules/frontend-stores.md | 31 + IMPLEMENTATION_PLAN_RECEIPT_OCR.md | 386 ++++++++++++ data-entry-app/CLAUDE.md | 92 +++ ...MPLEMENTATION_SUMMARY_NOMENCLATURE_SYNC.md | 455 -------------- data-entry-app/backend/NOMENCLATURE_SYNC.md | 273 --------- data-entry-app/backend/app/db/crud/receipt.py | 12 +- data-entry-app/backend/app/main.py | 10 + .../backend/app/routers/nomenclature.py | 64 +- .../backend/app/routers/receipts.py | 101 +++- .../backend/app/services/sync_service.py | 108 +++- data-entry-app/backend/migrations/env.py | 10 + .../docs/IMPLEMENTATION_PLAN_AUTH_EXEC.md | 346 ----------- .../docs/IMPLEMENTATION_PLAN_AUTH_UNITAR.md | 484 --------------- data-entry-app/frontend/src/App.vue | 238 ++++---- .../frontend/src/assets/css/main.css | 36 ++ .../src/components/ocr/OCRUploadZone.vue | 4 +- data-entry-app/frontend/src/main.js | 2 + data-entry-app/frontend/src/services/api.js | 20 +- .../frontend/src/stores/accountingPeriod.js | 17 + .../frontend/src/stores/companies.js | 12 + .../src/views/receipts/ReceiptCreateView.vue | 12 +- data-entry-app/frontend/vite.config.js | 4 - reports-app/frontend/src/App.vue | 288 ++++++++- reports-app/frontend/src/assets/css/main.css | 4 + .../frontend/src/stores/accountingPeriod.js | 147 +---- reports-app/frontend/src/stores/companies.js | 211 +------ .../frontend/components/CompanySelector.vue | 555 ++++++++++++++++++ shared/frontend/components/PeriodSelector.vue | 441 ++++++++++++++ .../frontend/components/layout/AppHeader.vue | 132 +++++ .../frontend/components/layout/SlideMenu.vue | 101 ++++ shared/frontend/stores/accountingPeriod.js | 158 +++++ shared/frontend/stores/companies.js | 196 +++++++ shared/frontend/styles/layout/header.css | 167 ++++++ shared/frontend/styles/layout/navigation.css | 151 +++++ shared/models/__init__.py | 11 + shared/models/calendar.py | 18 + shared/models/company.py | 19 + shared/routes/__init__.py | 21 + shared/routes/calendar.py | 136 +++++ shared/routes/companies.py | 175 ++++++ start-data-entry-dev.sh | 224 +++++++ start-data-entry-test.sh | 224 +++++++ start-data-entry.sh | 472 --------------- 47 files changed, 4079 insertions(+), 2595 deletions(-) create mode 100644 .claude/rules/authentication.md create mode 100644 .claude/rules/backend-patterns.md create mode 100644 .claude/rules/company-period.md create mode 100644 .claude/rules/css-design-system.md create mode 100644 .claude/rules/frontend-stores.md create mode 100644 IMPLEMENTATION_PLAN_RECEIPT_OCR.md delete mode 100644 data-entry-app/backend/IMPLEMENTATION_SUMMARY_NOMENCLATURE_SYNC.md delete mode 100644 data-entry-app/backend/NOMENCLATURE_SYNC.md delete mode 100644 data-entry-app/docs/IMPLEMENTATION_PLAN_AUTH_EXEC.md delete mode 100644 data-entry-app/docs/IMPLEMENTATION_PLAN_AUTH_UNITAR.md create mode 100644 data-entry-app/frontend/src/stores/accountingPeriod.js create mode 100644 data-entry-app/frontend/src/stores/companies.js create mode 100644 shared/frontend/components/CompanySelector.vue create mode 100644 shared/frontend/components/PeriodSelector.vue create mode 100644 shared/frontend/components/layout/AppHeader.vue create mode 100644 shared/frontend/components/layout/SlideMenu.vue create mode 100644 shared/frontend/stores/accountingPeriod.js create mode 100644 shared/frontend/stores/companies.js create mode 100644 shared/frontend/styles/layout/header.css create mode 100644 shared/frontend/styles/layout/navigation.css create mode 100644 shared/models/__init__.py create mode 100644 shared/models/calendar.py create mode 100644 shared/models/company.py create mode 100644 shared/routes/__init__.py create mode 100644 shared/routes/calendar.py create mode 100644 shared/routes/companies.py create mode 100644 start-data-entry-dev.sh create mode 100644 start-data-entry-test.sh delete mode 100644 start-data-entry.sh diff --git a/.claude/rules/authentication.md b/.claude/rules/authentication.md new file mode 100644 index 0000000..acc7546 --- /dev/null +++ b/.claude/rules/authentication.md @@ -0,0 +1,17 @@ +# Authentication Rules + +## JWT Token Structure (IMMUTABLE) +All apps use the same token payload: +- `username`, `user_id`, `companies[]`, `permissions[]`, `exp`, `iat`, `type` + +## Backend Rules +- Use `AuthenticationMiddleware` from `shared/auth/middleware.py` +- Use `get_current_user` dependency from `shared/auth/dependencies.py` +- Never implement custom auth logic in routers +- Rate limiting: 5 req/5 min for /auth/* paths + +## Frontend Rules +- Use `createAuthStore(apiService)` factory from `shared/frontend/stores/auth.js` +- Use `LoginView.vue` component from `shared/frontend/components/` +- Store tokens in localStorage: `access_token`, `refresh_token`, `user` +- Initialize auth on app startup with `initializeAuth()` diff --git a/.claude/rules/backend-patterns.md b/.claude/rules/backend-patterns.md new file mode 100644 index 0000000..e2d6495 --- /dev/null +++ b/.claude/rules/backend-patterns.md @@ -0,0 +1,44 @@ +--- +paths: {reports-app,data-entry-app}/backend/**/*.py +--- + +# Backend Patterns + +## Router Factory Pattern +Use shared router factories instead of custom implementations: +```python +from shared.routes.companies import create_companies_router +from shared.routes.calendar import create_calendar_router + +companies_router = create_companies_router(oracle_pool, cache_decorator=cached) +app.include_router(companies_router, prefix="/api/companies") +``` + +## Database Queries +- All queries use parameterized Oracle queries (no SQL injection) +- Schema lookup: `SELECT SCHEMA FROM CONTAFIN_ORACLE.V_NOM_FIRME` +- Company access: Join V_NOM_FIRME with VDEF_UTIL_FIRME + +## Caching (reports-app) +- Use `@cached` decorator from `app/cache/decorators` +- Place logic in services, not routers +- Cache schema lookups (24h TTL) +- Cache user data (10-30 min TTL) + +## Error Handling +```python +try: + # Business logic +except HTTPException: + raise # Re-raise HTTP exceptions +except Exception as e: + logger.error(...) + raise HTTPException(500, "Internal error") +``` + +## Oracle Pool Pattern +```python +async with oracle_pool.get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute(query, params) +``` diff --git a/.claude/rules/company-period.md b/.claude/rules/company-period.md new file mode 100644 index 0000000..dc27ca8 --- /dev/null +++ b/.claude/rules/company-period.md @@ -0,0 +1,19 @@ +# Company & Period Selection Rules + +## Store Factories (MANDATORY) +- Companies: Use `createCompaniesStore()` from `shared/frontend/stores/companies.js` +- Periods: Use `createAccountingPeriodStore()` from `shared/frontend/stores/accountingPeriod.js` +- Never implement custom company/period stores + +## Components +- Use `CompanySelector.vue` from `shared/frontend/components/` +- Use `PeriodSelector.vue` from `shared/frontend/components/` +- Use `AppHeader.vue` layout from `shared/frontend/components/layout/` + +## Backend Endpoints +- Use `create_companies_router()` factory from `shared/routes/companies.py` +- Use `create_calendar_router()` factory from `shared/routes/calendar.py` + +## Company Access Validation (2-step) +1. Check `current_user.companies` (fast, from JWT) +2. Validate against Oracle DB (authoritative) diff --git a/.claude/rules/css-design-system.md b/.claude/rules/css-design-system.md new file mode 100644 index 0000000..58b970f --- /dev/null +++ b/.claude/rules/css-design-system.md @@ -0,0 +1,26 @@ +--- +paths: {reports-app,data-entry-app}/frontend/**/*.{vue,css} +--- + +# CSS Design System Rules + +## Documentation (READ FIRST) +- **Quick Start**: `docs/ONBOARDING_CSS.md` (5 min read) +- **Complete Patterns**: `docs/CSS_PATTERNS.md` (cards, forms, buttons, tables, etc.) +- **Design Tokens**: `docs/DESIGN_TOKENS.md` (colors, spacing, typography variables) + +## Core Principles +- Use CSS variables from design tokens, NEVER hardcoded values +- Check `CSS_PATTERNS.md` BEFORE writing any CSS +- Import shared styles from `shared/frontend/styles/` + +## Shared Styles to Import +- Login: `@import 'shared/frontend/styles/login.css'` +- Header: `@import 'shared/frontend/styles/layout/header.css'` +- Navigation: `@import 'shared/frontend/styles/layout/navigation.css'` + +## NEVER +- Use `:deep()` for PrimeVue overrides (use `vendor/` files) +- Duplicate patterns that exist in CSS_PATTERNS.md +- Use hardcoded colors like `#2563eb` (use `var(--color-primary)`) +- Create scoped CSS for patterns that already exist in shared files diff --git a/.claude/rules/frontend-stores.md b/.claude/rules/frontend-stores.md new file mode 100644 index 0000000..957f32f --- /dev/null +++ b/.claude/rules/frontend-stores.md @@ -0,0 +1,31 @@ +--- +paths: {reports-app,data-entry-app}/frontend/src/stores/**/*.js +--- + +# Frontend Store Rules + +## Factory Pattern (MANDATORY) +All stores MUST use shared factories: + +```javascript +// Correct - use factory +import { createAuthStore } from '@shared/frontend/stores/auth'; +export const useAuthStore = createAuthStore(apiService); + +// Correct - use factory +import { createCompaniesStore } from '@shared/frontend/stores/companies'; +export const useCompaniesStore = createCompaniesStore(apiService, useAuthStore); + +// WRONG - custom implementation +export const useCompaniesStore = defineStore('companies', () => { ... }); +``` + +## Available Factories +- `createAuthStore(apiService)` - Authentication state +- `createCompaniesStore(apiService, useAuthStore)` - Company selection +- `createAccountingPeriodStore(apiService, useAuthStore, useCompanyStore)` - Period selection + +## LocalStorage Keys (RESERVED) +- `access_token`, `refresh_token`, `user` - Auth +- `selected_company_id` - Company selection +- `selected_period_id` - Period selection diff --git a/IMPLEMENTATION_PLAN_RECEIPT_OCR.md b/IMPLEMENTATION_PLAN_RECEIPT_OCR.md new file mode 100644 index 0000000..55919b4 --- /dev/null +++ b/IMPLEMENTATION_PLAN_RECEIPT_OCR.md @@ -0,0 +1,386 @@ +# Plan: Receipt Scanning Workflow Improvements + +> **Context Handover Document** - Created for session continuity +> **Date**: 2025-12-15 +> **Status**: Ready for implementation + +## Overview +Improve the data-entry-app receipt scanning to: +1. Save supplier name, CUI, and OCR text in drafts +2. Make supplier validation assistive (not blocking) +3. Unify create/edit forms with OCR rescan capability +4. Fix image resize bug (>4000px) +5. **NEW: Extract payment methods (CARD/NUMERAR) from OCR** + +## Requirements Summary +- **Drafts**: Save `cui` + `partner_name` + `ocr_raw_text` + `payment_methods` from OCR +- **Supplier match**: Auto-fill but editable (for assistance, not validation) +- **No match**: Show warning only, allow saving draft +- **Edit mode**: Allow OCR rescan on existing drafts +- **Approval**: Requires valid `cui` only (NOT partner_id) - ROA has stored procedure for supplier lookup +- **Image resize**: Cap at 4000px BEFORE upscaling +- **Payment methods**: Extract CARD/NUMERAR amounts (after TOTAL LEI, before TOTAL TVA) + +--- + +## Part 1: Backend Model & Database + +### 1.1 Add Fields to Receipt Model +**File**: `data-entry-app/backend/app/db/models/receipt.py` + +Add after line 66 (after `partner_name`): +```python +cui: Optional[str] = Field(default=None, max_length=20) # Fiscal code from OCR +ocr_raw_text: Optional[str] = Field(default=None) # Raw OCR text for debugging +payment_methods: Optional[str] = Field(default=None, max_length=500) # JSON: [{"method":"CARD","amount":"50.00"}] +``` + +### 1.2 Create Alembic Migration +**File**: `data-entry-app/backend/migrations/versions/XXXX_add_ocr_fields.py` + +```python +def upgrade(): + with op.batch_alter_table('receipts') as batch_op: + batch_op.add_column(sa.Column('cui', sa.String(20), nullable=True)) + batch_op.add_column(sa.Column('ocr_raw_text', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('payment_methods', sa.String(500), nullable=True)) +``` + +### 1.3 Update Pydantic Schemas +**File**: `data-entry-app/backend/app/schemas/receipt.py` + +**Add PaymentMethodSchema** (after TvaEntrySchema ~line 75): +```python +class PaymentMethodSchema(BaseModel): + """Payment method entry (CARD/NUMERAR).""" + method: str = Field(description="Payment method: CARD or NUMERAR") + amount: Decimal = Field(description="Amount paid with this method") +``` + +**ReceiptBase** (after line 97): +```python +cui: Optional[str] = Field(default=None, max_length=20) +ocr_raw_text: Optional[str] = Field(default=None) +payment_methods: Optional[List[PaymentMethodSchema]] = Field(default=None, description="Payment methods from OCR") +``` + +**ReceiptUpdate** (after line 125): +```python +cui: Optional[str] = Field(default=None, max_length=20) +ocr_raw_text: Optional[str] = Field(default=None) +payment_methods: Optional[List[PaymentMethodSchema]] = Field(default=None) +``` + +**ReceiptResponse**: Add validator to parse `payment_methods` from JSON (similar to `parse_tva_breakdown`) + +--- + +## Part 2: Fix Image Resize Bug + +**File**: `data-entry-app/backend/app/services/image_preprocessor.py` + +### 2.1 Update `preprocess_light()` (after line 55) +Add downscale BEFORE upscale: +```python +# 2a. Scale DOWN if any side exceeds 4000px (PaddleOCR limit) +height, width = gray.shape +max_side = max(height, width) +if max_side > 4000: + scale = 4000 / max_side + gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) + height, width = gray.shape + +# 2b. Scale UP if too small +if width < 1500: + scale = 1500 / width + gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC) +``` + +### 2.2 Update `preprocess_heavy()` (after line 82) +Same downscale logic before the existing upscale at lines 85-88. + +--- + +## Part 3: Backend OCR Endpoint - Return Raw Text + +**File**: `data-entry-app/backend/app/routers/ocr.py` + +Ensure the OCR extraction endpoint returns `raw_text` in the response (verify this is already included in the OCR service output). + +--- + +## Part 4: Frontend Form Unification + +### 4.1 Unify OCR Zone for Create & Edit +**File**: `data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue` + +**Change line 19** from: +```vue +
+``` +to: +```vue +
+``` + +**Update header text** (around line 23): +```vue +

+ + {{ isEditMode ? 'Re-scanare OCR (opศ›ional)' : 'Pozฤƒ Bon (obligatoriu)' }} +

+``` + +### 4.2 Add CUI Field to Form State +**File**: `data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue` + +Add to form ref initialization: +```javascript +cui: '', +ocr_raw_text: '', +``` + +### 4.3 Add CUI Display Field +Add after Furnizor dropdown (around line 210): +```vue +
+ + + + + CUI negฤƒsit รฎn nomenclator + +
+``` + +### 4.4 Change Supplier Dialog to Warning Banner +**Current behavior** (lines 555-563): When CUI not found, opens blocking dialog. + +**New behavior**: Show non-blocking warning message. + +Replace the `else` block in `applyOCRData()`: +```javascript +} else { + // Not found - show warning but allow continuing + supplierWarning.value = { + show: true, + cui: data.cui, + name: data.partner_name || '' + } + // Still set form values from OCR + form.value.cui = data.cui + form.value.partner_name = data.partner_name || '' + + toast.add({ + severity: 'warn', + summary: 'Furnizor negฤƒsit', + detail: `CUI ${data.cui} nu a fost gฤƒsit รฎn nomenclator`, + life: 5000 + }) +} +``` + +Add ref for warning state: +```javascript +const supplierWarning = ref({ show: false, cui: '', name: '' }) +``` + +### 4.5 Update `applyOCRData()` to Save Raw Text +Add to the function: +```javascript +if (data.cui) form.value.cui = data.cui +if (data.raw_text) form.value.ocr_raw_text = data.raw_text +``` + +### 4.6 Update `loadReceipt()` for Edit Mode +Add to existing field mapping: +```javascript +cui: receipt.value.cui || '', +ocr_raw_text: receipt.value.ocr_raw_text || '', +``` + +--- + +## Part 5: Backend Approval Validation + +**File**: `data-entry-app/backend/app/services/receipt_service.py` + +In `approve_receipt()` method, add validation: +```python +if not receipt.cui: + return False, "Trebuie completat codul fiscal (CUI) pentru aprobare", None +``` + +**Note**: At approval, only `cui` (fiscal code) is required, NOT `partner_id`. +The ROA ERP has a stored procedure that searches/creates suppliers based on `cui`. +The `partner_id` is only populated later during Oracle import phase. + +--- + +## Part 6: OCR Payment Methods Extraction + +### 6.1 Update ExtractionResult Dataclass +**File**: `data-entry-app/backend/app/services/ocr_extractor.py` + +Add to `ExtractionResult` (after line 24, after `items_count`): +```python +payment_methods: List[dict] = field(default_factory=list) # [{"method":"CARD","amount":Decimal}] +``` + +### 6.2 Add Payment Method Patterns +**File**: `data-entry-app/backend/app/services/ocr_extractor.py` + +Add new patterns (after TVA_PATTERNS ~line 184): +```python +# Payment method patterns - appears after TOTAL LEI, before TOTAL TVA +# Format: "CARD: 50.00" or "NUMERAR 100.00" or "PLATA CARD: 50.00" +PAYMENT_METHOD_PATTERNS = [ + # CARD with amount + (r'(?:PLATA\s+)?CARD\s*:?\s*([\d\s.,]+)', 'CARD', 0.95), + # NUMERAR (cash) with amount + (r'NUMERAR\s*:?\s*([\d\s.,]+)', 'NUMERAR', 0.95), + # CASH alternative spelling + (r'CASH\s*:?\s*([\d\s.,]+)', 'NUMERAR', 0.90), +] +``` + +### 6.3 Add Extraction Method +**File**: `data-entry-app/backend/app/services/ocr_extractor.py` + +Add new method `_extract_payment_methods()` (after `_extract_address` ~line 996): +```python +def _extract_payment_methods(self, text: str) -> List[dict]: + """ + Extract payment methods (CARD/NUMERAR) from receipt. + These appear after TOTAL LEI and before TOTAL TVA section. + + Returns list of: {'method': 'CARD'/'NUMERAR', 'amount': Decimal} + """ + payment_methods = [] + seen_methods = set() + + # Normalize spaces in numbers + normalized_text = re.sub(r'(\d+)[.,]\s+(\d{2})', r'\1.\2', text) + + # Find the region between TOTAL LEI and TOTAL TVA + total_lei_match = re.search(r'TOTAL\s+LEI\s*([\d\s.,]+)', normalized_text, re.IGNORECASE) + total_tva_match = re.search(r'TOTAL\s+T[VU][AR]', normalized_text, re.IGNORECASE) + + # Define search region (after TOTAL LEI, before TOTAL TVA if exists) + if total_lei_match: + start_pos = total_lei_match.end() + end_pos = total_tva_match.start() if total_tva_match else len(normalized_text) + search_region = normalized_text[start_pos:end_pos] + else: + search_region = normalized_text # Fallback to full text + + for pattern, method, confidence in self.PAYMENT_METHOD_PATTERNS: + for match in re.finditer(pattern, search_region, re.IGNORECASE): + try: + amount_str = match.group(1).replace(' ', '') + amount_str = self._normalize_number(re.sub(r'[^\d.,]', '', amount_str)) + amount = Decimal(amount_str) + if amount > 0 and method not in seen_methods: + payment_methods.append({ + 'method': method, + 'amount': amount + }) + seen_methods.add(method) + except (InvalidOperation, ValueError): + continue + + return payment_methods +``` + +### 6.4 Call Extraction in `extract()` Method +**File**: `data-entry-app/backend/app/services/ocr_extractor.py` + +Add to `extract()` method (after line 255, after `result.address = ...`): +```python +result.payment_methods = self._extract_payment_methods(text_upper) +``` + +### 6.5 Frontend - Add Payment Methods Display +**File**: `data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue` + +Add to form ref: +```javascript +payment_methods: [], +``` + +Add to `applyOCRData()`: +```javascript +if (data.payment_methods) form.value.payment_methods = data.payment_methods +``` + +Add UI display (after TVA breakdown section): +```vue + +
+ +
+ +
+
+``` + +--- + +## Implementation Order + +| Step | Task | Files | +|------|------|-------| +| 1 | Add `cui`, `ocr_raw_text`, `payment_methods` to model | `models/receipt.py` | +| 2 | Create migration | `migrations/versions/...` | +| 3 | Update schemas | `schemas/receipt.py` | +| 4 | Fix image resize | `services/image_preprocessor.py` | +| 5 | Add payment methods extraction to OCR | `services/ocr_extractor.py` | +| 6 | Unify frontend form + add new fields | `views/receipts/ReceiptCreateView.vue` | +| 7 | Add approval validation | `services/receipt_service.py` | +| 8 | Test full workflow | Manual testing | + +--- + +## Files to Modify + +### Backend +- `data-entry-app/backend/app/db/models/receipt.py` - Add cui, ocr_raw_text, payment_methods fields +- `data-entry-app/backend/app/schemas/receipt.py` - Add PaymentMethodSchema, update schemas +- `data-entry-app/backend/app/services/image_preprocessor.py` - Fix resize bug (cap at 4000px) +- `data-entry-app/backend/app/services/ocr_extractor.py` - Add payment methods extraction +- `data-entry-app/backend/app/services/receipt_service.py` - Add approval validation +- `data-entry-app/backend/migrations/versions/` - New migration + +### Frontend +- `data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue` - Unify form, add CUI + payment methods fields, change dialog to warning + +--- + +## Expected Behavior After Implementation + +1. **OCR Scan**: Extracts supplier name, CUI, raw text, payment methods โ†’ all saved to draft +2. **Payment Methods**: CARD/NUMERAR amounts extracted (after TOTAL LEI, before TOTAL TVA) +3. **CUI Match**: Auto-fills supplier name from ROA, user can edit +4. **CUI No Match**: Shows warning toast, allows saving draft with OCR data +5. **Edit Mode**: Can re-scan OCR to update extracted data +6. **Approval**: Requires valid `cui` (fiscal code) - NOT partner_id +7. **Oracle Import** (later): Uses `cui` to find/create supplier via ROA stored procedure +8. **Large Images**: Automatically resized to max 4000px before OCR + +--- + +## Romanian Receipt Structure Reference +``` +NUME FIRMA S.R.L. +CIF: RO12345678 +STR. EXEMPLU NR. 1 + +[Product lines...] + +TOTAL LEI 150.00 โ† Total amount +CARD 50.00 โ† Payment method 1 (NEW) +NUMERAR 100.00 โ† Payment method 2 (NEW) +TOTAL TVA A-19% 23.95 โ† TVA breakdown +``` diff --git a/data-entry-app/CLAUDE.md b/data-entry-app/CLAUDE.md index e1ff9f4..806a463 100644 --- a/data-entry-app/CLAUDE.md +++ b/data-entry-app/CLAUDE.md @@ -57,6 +57,89 @@ data-entry-app/ - `shared/frontend/stores/auth.js` - Pinia auth store factory - `shared/frontend/styles/login.css` - Stiluri login +## Servere Oracle (Producศ›ie vs Test) + +**IMPORTANT**: Existฤƒ douฤƒ servere Oracle separate. Verificฤƒ รฎntotdeauna la care eศ™ti conectat! + +| Server | IP Oracle | Tunel SSH | Schema Verificare | Company ID | +|--------|-----------|-----------|-------------------|------------| +| **PRODUCศšIE** | `10.0.20.36` | `./ssh_tunnel.sh` | `ROMFAST` | 114 | +| **TEST** | `10.0.20.121` | `./ssh-tunnel-test.sh` | `MARIUSM_AUTO` | 110 | + +### Scripturi de Pornire + +**IMPORTANT**: Foloseศ™te scriptul corespunzฤƒtor mediului dorit! + +```bash +# Pentru PRODUCศšIE (10.0.20.36) +./start-data-entry-dev.sh # Porneศ™te tot (tunel + backend + frontend) +./start-data-entry-dev.sh stop # Opreศ™te tot +./start-data-entry-dev.sh status # Verificฤƒ status + +# Pentru TEST (10.0.20.121) +./start-data-entry-test.sh # Porneศ™te tot (tunel + backend + frontend) +./start-data-entry-test.sh stop # Opreศ™te tot +./start-data-entry-test.sh status # Verificฤƒ status +``` + +Scripturile fac automat: +1. Opresc tunelul celuilalt mediu (dacฤƒ ruleazฤƒ) +2. Pornesc tunelul corect +3. Copiazฤƒ `.env.prod` sau `.env.test` รฎn `.env` +4. Ruleazฤƒ migrฤƒrile pe baza de date corectฤƒ +5. Pornesc frontend ศ™i backend + +### Fiศ™iere .env + +| Fiศ™ier | Server | Database | Schema Test | +|--------|--------|----------|-------------| +| `.env.prod` | PRODUCศšIE (10.0.20.36) | `receipts_prod.db` | ROMFAST (id=114) | +| `.env.test` | TEST (10.0.20.121) | `receipts_test.db` | MARIUSM_AUTO (id=110) | + +### Verificare conexiune + +```bash +# Verificฤƒ la ce server eศ™ti conectat +./ssh_tunnel.sh status # Dacฤƒ ruleazฤƒ = PRODUCศšIE +./ssh-tunnel-test.sh status # Dacฤƒ ruleazฤƒ = TEST + +# Verificฤƒ schema disponibilฤƒ (din backend/) +python3 -c " +import asyncio +from dotenv import load_dotenv +load_dotenv() +import sys; sys.path.insert(0, '../../shared') +from database.oracle_pool import oracle_pool + +async def check(): + await oracle_pool.initialize() + async with oracle_pool.get_connection() as conn: + with conn.cursor() as cur: + cur.execute('SELECT SCHEMA, NUME FROM CONTAFIN_ORACLE.V_NOM_FIRME ORDER BY NUME') + for row in cur.fetchall()[:10]: + print(f'{row[0]}: {row[1]}') +asyncio.run(check()) +" +``` + +### Sincronizare Nomenclatoare + +Query-ul pentru furnizori foloseศ™te `CORESP_TIP_PART`: +- `id_tip_part = 17` โ†’ Furnizori +- `id_tip_part = 22` โ†’ Casa LEI +- `id_tip_part = 23` โ†’ Casa Valutฤƒ +- `id_tip_part = 24` โ†’ Bancฤƒ LEI +- `id_tip_part = 25` โ†’ Bancฤƒ Valutฤƒ + +```sql +-- Exemplu query furnizori pentru schema MARIUSM_AUTO (TEST) +SELECT B.ID_PART, B.DENUMIRE, B.COD_FISCAL, B.ADRESA +FROM MARIUSM_AUTO.CORESP_TIP_PART A +INNER JOIN MARIUSM_AUTO.VNOM_PARTENERI B ON A.ID_PART = B.ID_PART +WHERE A.ID_TIP_PART = 17 AND (B.INACTIV = 0 OR B.INACTIV IS NULL) +ORDER BY B.DENUMIRE; +``` + ## Comenzi Dezvoltare ```bash @@ -135,6 +218,15 @@ cd frontend && npm run test ## Common Issues +### Nomenclatoare goale / furnizori lipsฤƒ +- Verificฤƒ la ce server Oracle eศ™ti conectat: `./ssh_tunnel.sh status` +- Verificฤƒ dacฤƒ schema firmei selectate existฤƒ pe acel server +- Sincronizeazฤƒ manual: `POST /api/nomenclature/sync/all` + +### Conectat la serverul greศ™it +- PRODUCศšIE are schema `ROMFAST`, TEST are schema `MARIUSM_AUTO` +- Opreศ™te tunelul curent ศ™i porneศ™te cel corect (vezi secศ›iunea "Servere Oracle") + ### SQLite locked - Asigura-te ca nu ai multiple procese care acceseaza DB-ul diff --git a/data-entry-app/backend/IMPLEMENTATION_SUMMARY_NOMENCLATURE_SYNC.md b/data-entry-app/backend/IMPLEMENTATION_SUMMARY_NOMENCLATURE_SYNC.md deleted file mode 100644 index 7e29aa6..0000000 --- a/data-entry-app/backend/IMPLEMENTATION_SUMMARY_NOMENCLATURE_SYNC.md +++ /dev/null @@ -1,455 +0,0 @@ -# Implementation Summary: Nomenclature Sync (FAZA 3) - -**Date**: 2025-12-13 -**Status**: COMPLETED -**Developer**: Claude Code - ---- - -## Overview - -Successfully implemented FAZA 3: Nomenclature Sync for the data-entry-app. This feature enables the application to maintain a local SQLite cache of nomenclatures (suppliers, cash registers) from Oracle, reducing latency and improving performance. - -## Files Created - -### 1. Models -**File**: `/app/db/models/nomenclature.py` -- `SyncedSupplier` - Suppliers synced from Oracle NOM_PARTENERI -- `LocalSupplier` - Suppliers created locally from OCR (not yet in Oracle) -- `SyncedCashRegister` - Cash registers and bank accounts synced from Oracle - -### 2. Service Layer -**File**: `/app/services/sync_service.py` -- `SyncService.sync_suppliers()` - Sync suppliers from Oracle to SQLite -- `SyncService.sync_cash_registers()` - Sync cash registers from Oracle to SQLite -- `SyncService.search_supplier()` - Search in synced + local suppliers -- `SyncService.create_local_supplier()` - Create local supplier from OCR data -- `SyncService.get_all_suppliers()` - Get all suppliers for dropdown -- `SyncService.get_all_cash_registers()` - Get all cash registers for dropdown -- `SyncService.get_schema_for_company()` - Map company ID to Oracle schema - -**Company-to-Schema Mapping**: -```python -COMPANY_SCHEMAS = { - 1: "CONTAFIN", - 2: "CONTAFIN2", -} -``` -> **TODO**: Move to config table or environment variable - -### 3. API Router -**File**: `/app/routers/nomenclature.py` - -New endpoints: -- `GET /api/nomenclature/suppliers` - Get all suppliers (synced + local) -- `GET /api/nomenclature/suppliers/search` - Search supplier by fiscal code or name -- `POST /api/nomenclature/suppliers/local` - Create local supplier from OCR -- `GET /api/nomenclature/cash-registers` - Get all cash registers -- `POST /api/nomenclature/sync/suppliers` - Manual supplier sync -- `POST /api/nomenclature/sync/cash-registers` - Manual cash register sync -- `POST /api/nomenclature/sync/all` - Sync all nomenclatures - -### 4. Database Migration -**File**: `/migrations/versions/20251213_002805_add_nomenclature_tables.py` -- Creates `synced_suppliers` table with indexes -- Creates `local_suppliers` table with indexes -- Creates `synced_cash_registers` table with indexes - -**Applied**: Yes (migration revision: 3a653da79002) - -### 5. Documentation -**File**: `NOMENCLATURE_SYNC.md` -- Complete implementation guide -- Architecture overview -- API reference -- Usage examples -- Troubleshooting guide - -**File**: `IMPLEMENTATION_SUMMARY_NOMENCLATURE_SYNC.md` (this file) -- Implementation summary -- Files changed -- Testing checklist - -## Files Modified - -### 1. `/app/db/models/__init__.py` -**Change**: Added imports for nomenclature models -```python -from .nomenclature import SyncedSupplier, LocalSupplier, SyncedCashRegister -``` - -### 2. `/app/services/nomenclature_service.py` -**Changes**: -- Updated `get_partners()` to accept optional `session` parameter -- Added SQLite fallback: returns synced/local suppliers if available -- Falls back to mock data if no synced data -- Updated `get_cash_registers()` to accept optional `session` parameter -- Added SQLite fallback for cash registers - -### 3. `/app/routers/receipts.py` -**Changes**: -- Updated `get_partners()` endpoint to pass `session` to service -- Updated `get_cash_registers()` endpoint to pass `session` to service - -### 4. `/app/routers/__init__.py` -**Change**: Added nomenclature router to exports -```python -from . import receipts, nomenclature -__all__ = ["receipts", "nomenclature"] -``` - -### 5. `/app/main.py` -**Change**: Registered nomenclature router -```python -from app.routers import receipts, ocr, nomenclature -app.include_router(nomenclature.router, prefix="/api/nomenclature", tags=["nomenclature"]) -``` - -## Database Schema - -### synced_suppliers -```sql -CREATE TABLE synced_suppliers ( - id INTEGER PRIMARY KEY, - oracle_id INTEGER NOT NULL, - company_id INTEGER NOT NULL, - name VARCHAR(200) NOT NULL, - fiscal_code VARCHAR(50), - address VARCHAR(500), - synced_at DATETIME NOT NULL -); -CREATE INDEX ix_synced_suppliers_oracle_id ON synced_suppliers(oracle_id); -CREATE INDEX ix_synced_suppliers_company_id ON synced_suppliers(company_id); -CREATE INDEX ix_synced_suppliers_fiscal_code ON synced_suppliers(fiscal_code); -``` - -### local_suppliers -```sql -CREATE TABLE local_suppliers ( - id INTEGER PRIMARY KEY, - company_id INTEGER NOT NULL, - name VARCHAR(200) NOT NULL, - fiscal_code VARCHAR(50), - address VARCHAR(500), - created_by VARCHAR(100) NOT NULL, - created_at DATETIME NOT NULL, - pending_oracle_sync BOOLEAN NOT NULL -); -CREATE INDEX ix_local_suppliers_company_id ON local_suppliers(company_id); -CREATE INDEX ix_local_suppliers_fiscal_code ON local_suppliers(fiscal_code); -``` - -### synced_cash_registers -```sql -CREATE TABLE synced_cash_registers ( - id INTEGER PRIMARY KEY, - oracle_id INTEGER NOT NULL, - company_id INTEGER NOT NULL, - name VARCHAR(100) NOT NULL, - account_code VARCHAR(20) NOT NULL, - register_type VARCHAR(10) NOT NULL, - synced_at DATETIME NOT NULL -); -CREATE INDEX ix_synced_cash_registers_oracle_id ON synced_cash_registers(oracle_id); -CREATE INDEX ix_synced_cash_registers_company_id ON synced_cash_registers(company_id); -``` - -## API Endpoints Summary - -### Nomenclature Endpoints - -#### GET /api/nomenclature/suppliers -Get all suppliers (synced + local) for dropdown/autocomplete. - -**Query Params**: -- `search` (optional) - Filter by name or fiscal code -- `company_id` (optional) - Company ID (defaults to user's first company) - -**Response**: -```json -[ - { - "id": 1, - "oracle_id": 123, - "name": "OMV Petrom", - "fiscal_code": "RO123456", - "source": "synced" - }, - { - "id": 2, - "name": "Local Supplier SRL", - "fiscal_code": "RO789012", - "source": "local" - } -] -``` - -#### GET /api/nomenclature/suppliers/search -Search for supplier by fiscal code or name. - -**Query Params**: -- `fiscal_code` (optional) - Fiscal code to search -- `name` (optional) - Name to search (partial match) -- `company_id` (optional) - Company ID - -**Response**: -```json -{ - "found": true, - "supplier": { - "id": 1, - "oracle_id": 123, - "name": "OMV Petrom", - "fiscal_code": "RO123456", - "address": "Str. Example 123" - }, - "source": "synced" -} -``` - -#### POST /api/nomenclature/suppliers/local -Create a local supplier from OCR data. - -**Body**: -```json -{ - "name": "New Supplier SRL", - "fiscal_code": "RO12345678", - "address": "Str. Example 123" -} -``` - -**Response**: -```json -{ - "id": 5, - "name": "New Supplier SRL", - "fiscal_code": "RO12345678", - "address": "Str. Example 123", - "is_local": true -} -``` - -#### GET /api/nomenclature/cash-registers -Get all cash registers for a company. - -**Query Params**: -- `company_id` (optional) - Company ID - -**Response**: -```json -[ - { - "id": 1, - "oracle_id": 10, - "name": "Casa principala", - "account_code": "5311", - "register_type": "cash" - }, - { - "id": 2, - "oracle_id": 20, - "name": "Cont BCR", - "account_code": "5121", - "register_type": "bank" - } -] -``` - -#### POST /api/nomenclature/sync/suppliers -Manually trigger supplier sync from Oracle. - -**Response**: -```json -{ - "synced": 150, - "errors": 0, - "message": "Synced 150 suppliers with 0 errors" -} -``` - -#### POST /api/nomenclature/sync/cash-registers -Manually trigger cash register sync from Oracle. - -**Response**: -```json -{ - "synced": 5, - "errors": 0, - "message": "Synced 5 cash registers with 0 errors" -} -``` - -#### POST /api/nomenclature/sync/all -Sync all nomenclatures (suppliers + cash registers). - -**Response**: -```json -{ - "suppliers": { - "synced": 150, - "errors": 0 - }, - "cash_registers": { - "synced": 5, - "errors": 0 - }, - "total_synced": 155, - "total_errors": 0, - "message": "Synced 150 suppliers and 5 cash registers" -} -``` - -## Testing Checklist - -### Unit Tests -- [ ] Test `SyncService.sync_suppliers()` with mock Oracle data -- [ ] Test `SyncService.sync_cash_registers()` with mock Oracle data -- [ ] Test `SyncService.search_supplier()` for synced suppliers -- [ ] Test `SyncService.search_supplier()` for local suppliers -- [ ] Test `SyncService.create_local_supplier()` -- [ ] Test upsert logic (update existing vs insert new) - -### Integration Tests -- [ ] Test nomenclature router endpoints with authentication -- [ ] Test `/api/nomenclature/suppliers` endpoint -- [ ] Test `/api/nomenclature/suppliers/search` endpoint -- [ ] Test `/api/nomenclature/suppliers/local` endpoint -- [ ] Test `/api/nomenclature/cash-registers` endpoint -- [ ] Test `/api/nomenclature/sync/suppliers` endpoint -- [ ] Test `/api/nomenclature/sync/all` endpoint - -### Manual Testing -- [ ] Start backend: `uvicorn app.main:app --reload --port 8003` -- [ ] Verify `/docs` shows new nomenclature endpoints -- [ ] Test sync endpoint (requires Oracle connection) -- [ ] Test search endpoint with various queries -- [ ] Test create local supplier endpoint -- [ ] Verify existing `/api/receipts/nomenclature/partners` still works -- [ ] Verify existing `/api/receipts/nomenclature/cash-registers` still works - -### Oracle Connection Testing -- [ ] Verify SSH tunnel is running (dev/Linux) -- [ ] Test Oracle connection via health endpoint -- [ ] Verify company schema mapping is correct -- [ ] Test sync with real Oracle data -- [ ] Verify table names match actual Oracle schema - -### Error Handling Testing -- [ ] Test sync with invalid company ID -- [ ] Test sync with Oracle connection error -- [ ] Test search with no results -- [ ] Test create local supplier with duplicate fiscal code -- [ ] Test endpoints with missing authentication token - -## Dependencies - -All required dependencies are already in `requirements.txt`: -- `oracledb>=2.0.1` - Oracle database connection -- `sqlmodel>=0.0.14` - ORM for SQLite -- `alembic>=1.13.1` - Database migrations - -## Deployment Notes - -### Development -1. Ensure SSH tunnel is running: `./ssh_tunnel.sh start` -2. Apply migration: `alembic upgrade head` -3. Run initial sync: `POST /api/nomenclature/sync/all` -4. Start backend: `uvicorn app.main:app --reload --port 8003` - -### Production -1. Update `COMPANY_SCHEMAS` in `sync_service.py` with production mappings -2. Apply migration: `alembic upgrade head` -3. Set up cron job for periodic sync (daily recommended) -4. Configure Oracle connection (no SSH tunnel needed on Windows prod) - -### Migration Commands -```bash -# Check current version -alembic current - -# Apply migration -alembic upgrade head - -# Rollback migration -alembic downgrade -1 - -# View migration history -alembic history -``` - -## Known Issues / TODOs - -1. **Company Schema Mapping**: Currently hardcoded in `sync_service.py` - - TODO: Move to config table or environment variable - -2. **Oracle Table Names**: Assumes `NOM_PARTENERI` and `NOM_CASE` exist - - TODO: Verify actual table names in production Oracle schema - - TODO: Add error handling for missing tables - -3. **Sync Scheduling**: No automatic periodic sync implemented - - TODO: Add background task or cron job for daily sync - -4. **Conflict Resolution**: No logic to handle local supplier matching synced supplier - - TODO: Implement merge logic when OCR supplier matches Oracle supplier - -5. **Bidirectional Sync**: Local suppliers not pushed to Oracle - - TODO: Implement sync from SQLite to Oracle for approved local suppliers - -6. **Performance**: Sync loads all records at once - - TODO: Implement batch processing for large datasets - - TODO: Add incremental sync (requires Oracle last_modified timestamp) - -7. **Validation**: No validation for duplicate fiscal codes - - TODO: Add uniqueness constraint and conflict resolution - -8. **Testing**: No unit tests written yet - - TODO: Add comprehensive test suite - -## Success Criteria - -โœ… **Completed**: -- SQLite tables created for synced nomenclatures -- Sync service implemented with Oracle integration -- API endpoints for sync and query operations -- Updated existing nomenclature service to use synced data -- Database migration created and applied -- All files have correct Python syntax -- Documentation created - -โณ **Pending**: -- Unit tests -- Integration tests -- Manual testing with real Oracle data -- Production deployment -- Scheduled sync setup - -## Next Steps - -1. **Testing Phase**: - - Write unit tests for sync service - - Write integration tests for API endpoints - - Manual testing with real Oracle connection - - Performance testing with large datasets - -2. **Production Readiness**: - - Update company schema mappings for production - - Verify Oracle table names - - Set up cron job for periodic sync - - Add monitoring and alerting - -3. **Enhancements**: - - Implement scheduled background sync - - Add sync status dashboard in frontend - - Implement conflict resolution - - Add bidirectional sync (SQLite โ†’ Oracle) - -## Related Documentation - -- Complete Guide: `NOMENCLATURE_SYNC.md` -- Architecture: `docs/data-entry/ARCHITECTURE.md` -- API Docs: Available at `/docs` when app is running - ---- - -**Implementation completed successfully!** All core features are in place and ready for testing. diff --git a/data-entry-app/backend/NOMENCLATURE_SYNC.md b/data-entry-app/backend/NOMENCLATURE_SYNC.md deleted file mode 100644 index 32530c1..0000000 --- a/data-entry-app/backend/NOMENCLATURE_SYNC.md +++ /dev/null @@ -1,273 +0,0 @@ -# Nomenclature Sync - Implementation Guide - -## Overview - -This document describes the implementation of FAZA 3: Nomenclature Sync for the Data Entry App. - -The nomenclature sync system allows the data-entry-app to maintain a local SQLite cache of nomenclatures (suppliers, cash registers) from Oracle, reducing the need for live Oracle queries and improving performance. - -## Architecture - -### Database Tables - -Three new SQLite tables were added: - -1. **synced_suppliers** - Suppliers synced from Oracle NOM_PARTENERI - - `oracle_id` - Original Oracle ID - - `company_id` - Company this supplier belongs to - - `name` - Supplier name - - `fiscal_code` - CUI/CIF - - `address` - Supplier address - - `synced_at` - Last sync timestamp - -2. **local_suppliers** - Suppliers created locally from OCR (not in Oracle) - - `company_id` - Company ID - - `name` - Supplier name - - `fiscal_code` - CUI/CIF - - `address` - Supplier address - - `created_by` - Username who created it - - `pending_oracle_sync` - Flag for future Oracle sync - -3. **synced_cash_registers** - Cash registers and bank accounts from Oracle - - `oracle_id` - Original Oracle ID - - `company_id` - Company ID - - `name` - Register name - - `account_code` - Account code (5311, 5121, etc.) - - `register_type` - 'cash' or 'bank' - - `synced_at` - Last sync timestamp - -### Components - -#### 1. Models (`app/db/models/nomenclature.py`) -SQLModel models for the three tables above. - -#### 2. Sync Service (`app/services/sync_service.py`) -Core business logic for syncing nomenclatures: - -- `sync_suppliers()` - Sync suppliers from Oracle to SQLite -- `sync_cash_registers()` - Sync cash registers from Oracle to SQLite -- `search_supplier()` - Search in synced + local suppliers -- `create_local_supplier()` - Create local supplier from OCR data -- `get_all_suppliers()` - Get all suppliers for dropdown -- `get_all_cash_registers()` - Get all cash registers for dropdown - -#### 3. API Router (`app/routers/nomenclature.py`) -New API endpoints: - -**GET /api/nomenclature/suppliers** -- Get all suppliers (synced + local) for dropdown/autocomplete -- Query params: `search`, `company_id` -- Returns: List of SupplierOption - -**GET /api/nomenclature/suppliers/search** -- Search for supplier by fiscal code or name -- Query params: `fiscal_code`, `name`, `company_id` -- Returns: SupplierSearchResult (found, supplier, source) - -**POST /api/nomenclature/suppliers/local** -- Create a local supplier from OCR data -- Body: LocalSupplierCreate (name, fiscal_code, address) -- Returns: LocalSupplierResponse - -**GET /api/nomenclature/cash-registers** -- Get all cash registers for a company -- Query params: `company_id` -- Returns: List of CashRegisterOption - -**POST /api/nomenclature/sync/suppliers** -- Manually trigger supplier sync from Oracle -- Returns: SyncResult (synced count, errors) - -**POST /api/nomenclature/sync/cash-registers** -- Manually trigger cash register sync from Oracle -- Returns: SyncResult (synced count, errors) - -**POST /api/nomenclature/sync/all** -- Sync all nomenclatures (suppliers + cash registers) -- Returns: Combined sync results - -#### 4. Updated Services - -**nomenclature_service.py** was updated to use synced data: -- `get_partners()` - Now accepts optional `session` parameter, returns synced data if available, falls back to mock -- `get_cash_registers()` - Now accepts optional `session` parameter, returns synced data if available, falls back to mock - -**receipts.py router** was updated to pass session to nomenclature service. - -## Company Schema Mapping - -The sync service needs to know which Oracle schema to query for each company. This is configured in `sync_service.py`: - -```python -COMPANY_SCHEMAS = { - 1: "CONTAFIN", - 2: "CONTAFIN2", -} -``` - -**TODO**: Move this to a config table or environment variable for production. - -## Oracle Integration - -The sync service connects to Oracle using the shared `oracle_pool` from `/shared/database/oracle_pool.py`. - -**Prerequisites**: -- SSH tunnel must be running (development/Linux) -- Oracle connection pool must be initialized -- Environment variables must be set (ORACLE_USER, ORACLE_PASSWORD, ORACLE_HOST, ORACLE_PORT, ORACLE_SID) - -**Oracle Tables Used**: -- `{schema}.NOM_PARTENERI` - Suppliers (WHERE ACTIV = 1) -- `{schema}.NOM_CASE` - Cash registers (WHERE ACTIV = 1) - -**Note**: Table and column names may need adjustment based on actual Oracle schema. - -## Usage Flow - -### Initial Setup (One-time) - -1. Ensure Oracle connection is available: - ```bash - # Start SSH tunnel (if on Linux/dev) - ./ssh_tunnel.sh start - ``` - -2. Run initial sync: - ```bash - # Via API (authenticated request) - POST /api/nomenclature/sync/all - ``` - - Or programmatically: - ```python - from app.services.sync_service import SyncService - - # Sync for company 1 - synced, errors = await SyncService.sync_suppliers(session, company_id=1) - synced, errors = await SyncService.sync_cash_registers(session, company_id=1) - ``` - -### Periodic Sync - -Set up a cron job or scheduled task to sync nomenclatures periodically (e.g., daily): - -```python -# Example: Add to app lifespan or background task -async def sync_all_companies(): - """Sync nomenclatures for all companies.""" - async with get_db_session() as session: - for company_id in [1, 2]: # All company IDs - await SyncService.sync_suppliers(session, company_id) - await SyncService.sync_cash_registers(session, company_id) -``` - -### Using Synced Data - -The existing endpoints (`/api/receipts/nomenclature/partners`, `/api/receipts/nomenclature/cash-registers`) now automatically use synced data when available. - -**Frontend** - No changes needed! Existing code continues to work: -```javascript -// Get suppliers (now from synced data) -const response = await api.get('/api/receipts/nomenclature/partners?search=OMV'); -``` - -### Creating Local Suppliers from OCR - -When OCR extracts a supplier not in Oracle: - -```javascript -// Create local supplier -const response = await api.post('/api/nomenclature/suppliers/local', { - name: "New Supplier SRL", - fiscal_code: "RO12345678", - address: "Str. Example 123" -}); -``` - -The local supplier will be: -- Available immediately in dropdowns -- Flagged for future Oracle sync (`pending_oracle_sync = True`) -- Created by current user (`created_by = username`) - -## Migration - -Migration: `20251213_002805_add_nomenclature_tables.py` - -Applied with: -```bash -alembic upgrade head -``` - -To rollback: -```bash -alembic downgrade -1 -``` - -## Testing - -### Manual Testing - -1. Test sync endpoint: - ```bash - curl -X POST http://localhost:8003/api/nomenclature/sync/suppliers \ - -H "Authorization: Bearer YOUR_TOKEN" - ``` - -2. Test search: - ```bash - curl "http://localhost:8003/api/nomenclature/suppliers/search?name=OMV" \ - -H "Authorization: Bearer YOUR_TOKEN" - ``` - -3. Test get all suppliers: - ```bash - curl "http://localhost:8003/api/nomenclature/suppliers" \ - -H "Authorization: Bearer YOUR_TOKEN" - ``` - -### Unit Tests - -TODO: Add unit tests in `tests/test_sync_service.py`: -- Test supplier sync -- Test cash register sync -- Test search functionality -- Test local supplier creation - -## Troubleshooting - -### Sync fails with "No schema mapping" -- Update `COMPANY_SCHEMAS` in `sync_service.py` with correct company-to-schema mappings - -### Sync fails with Oracle connection error -- Verify SSH tunnel is running: `./ssh_tunnel.sh status` -- Check Oracle credentials in `.env` -- Test Oracle connection: `curl http://localhost:8003/health` - -### Tables not found in Oracle -- Verify table names in Oracle (may differ from NOM_PARTENERI, NOM_CASE) -- Update SQL queries in `sync_service.py` to match actual schema - -### Duplicate suppliers after sync -- The sync uses upsert logic (update if exists, insert if new) -- Check `oracle_id` + `company_id` uniqueness in synced_suppliers table - -## Future Enhancements - -1. **Scheduled Background Sync** - Add cron job or Celery task for automatic daily sync -2. **Sync Status Dashboard** - UI to show last sync time, sync statistics -3. **Conflict Resolution** - Handle cases where local supplier matches synced supplier -4. **Bidirectional Sync** - Push local suppliers to Oracle when approved -5. **Incremental Sync** - Only sync changed records (requires last_modified timestamp in Oracle) -6. **Multi-Company Support** - Auto-detect user's companies and sync all -7. **Sync Notifications** - Notify users when sync completes or fails -8. **Audit Log** - Track all sync operations for compliance - -## Related Files - -- Models: `/app/db/models/nomenclature.py` -- Service: `/app/services/sync_service.py` -- Router: `/app/routers/nomenclature.py` -- Migration: `/migrations/versions/20251213_002805_add_nomenclature_tables.py` -- Updated: `/app/services/nomenclature_service.py` -- Updated: `/app/routers/receipts.py` -- Updated: `/app/main.py` diff --git a/data-entry-app/backend/app/db/crud/receipt.py b/data-entry-app/backend/app/db/crud/receipt.py index 3243937..0995153 100644 --- a/data-entry-app/backend/app/db/crud/receipt.py +++ b/data-entry-app/backend/app/db/crud/receipt.py @@ -59,7 +59,9 @@ class ReceiptCRUD: session.add(receipt) await session.commit() await session.refresh(receipt) - return receipt + + # Reload with relationships to avoid lazy loading issues with async + return await ReceiptCRUD.get_by_id(session, receipt.id, include_relations=True) @staticmethod async def get_by_id( @@ -175,7 +177,9 @@ class ReceiptCRUD: session.add(receipt) await session.commit() await session.refresh(receipt) - return receipt + + # Reload with relationships to avoid lazy loading issues with async + return await ReceiptCRUD.get_by_id(session, receipt.id, include_relations=True) @staticmethod async def update_status( @@ -206,7 +210,9 @@ class ReceiptCRUD: session.add(receipt) await session.commit() await session.refresh(receipt) - return receipt + + # Reload with relationships to avoid lazy loading issues with async + return await ReceiptCRUD.get_by_id(session, receipt.id, include_relations=True) @staticmethod async def delete(session: AsyncSession, receipt: Receipt) -> bool: diff --git a/data-entry-app/backend/app/main.py b/data-entry-app/backend/app/main.py index 0c500c1..978e83e 100644 --- a/data-entry-app/backend/app/main.py +++ b/data-entry-app/backend/app/main.py @@ -132,6 +132,16 @@ from auth.routes import create_auth_router auth_router = create_auth_router(prefix="") # No prefix - we set it in include_router app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) +# Shared routes (companies, calendar) +from routes.companies import create_companies_router +from routes.calendar import create_calendar_router + +companies_router = create_companies_router(oracle_pool) # No cache for data-entry +calendar_router = create_calendar_router(oracle_pool) + +app.include_router(companies_router, prefix="/api/companies", tags=["companies"]) +app.include_router(calendar_router, prefix="/api/calendar", tags=["calendar"]) + # Root endpoint @app.get("/") diff --git a/data-entry-app/backend/app/routers/nomenclature.py b/data-entry-app/backend/app/routers/nomenclature.py index 70a4cee..1971453 100644 --- a/data-entry-app/backend/app/routers/nomenclature.py +++ b/data-entry-app/backend/app/routers/nomenclature.py @@ -1,7 +1,7 @@ """Nomenclature API endpoints.""" -from typing import Optional, List -from fastapi import APIRouter, Depends, HTTPException +from typing import Optional, List, Annotated +from fastapi import APIRouter, Depends, HTTPException, Header from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel @@ -20,6 +20,38 @@ from auth.models import CurrentUser router = APIRouter() +# ============ Selected Company Dependency ============ + +async def get_selected_company( + current_user: CurrentUser = Depends(get_current_user), + x_selected_company: Annotated[Optional[str], Header()] = None +) -> int: + """ + Get selected company from X-Selected-Company header. + Validates user access. Falls back to first company if no header. + """ + if x_selected_company: + try: + company_id = int(x_selected_company) + except ValueError: + raise HTTPException(400, f"Invalid company ID: {x_selected_company}") + + if str(company_id) in current_user.companies: + return company_id + raise HTTPException(403, f"Nu aveศ›i acces la firma {company_id}") + + if current_user.companies: + try: + return int(current_user.companies[0]) + except (ValueError, IndexError): + pass + + raise HTTPException(400, "Nu aveศ›i nicio firmฤƒ asignatฤƒ") + + +SelectedCompany = Annotated[int, Depends(get_selected_company)] + + # Request/Response Models class SupplierSearchResult(BaseModel): found: bool @@ -70,14 +102,13 @@ async def search_supplier( name: Optional[str] = None, company_id: Optional[int] = None, session: AsyncSession = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), + selected_company: SelectedCompany = None, ): """Search for supplier by fiscal code or name.""" if not fiscal_code and not name: raise HTTPException(status_code=400, detail="Provide fiscal_code or name") - # Use provided company_id or first from user - cid = company_id or (current_user.companies[0] if current_user.companies else 1) + cid = company_id or selected_company found, supplier, source = await SyncService.search_supplier( session, cid, fiscal_code, name @@ -91,10 +122,10 @@ async def get_suppliers( search: Optional[str] = None, company_id: Optional[int] = None, session: AsyncSession = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), + selected_company: SelectedCompany = None, ): """Get all suppliers (synced + local) for dropdown/autocomplete.""" - cid = company_id or (current_user.companies[0] if current_user.companies else 1) + cid = company_id or selected_company suppliers = await SyncService.get_all_suppliers(session, cid, search) @@ -115,10 +146,11 @@ async def create_local_supplier( data: LocalSupplierCreate, company_id: Optional[int] = None, session: AsyncSession = Depends(get_session), + selected_company: SelectedCompany = None, current_user: CurrentUser = Depends(get_current_user), ): """Create a local supplier from OCR data.""" - cid = company_id or (current_user.companies[0] if current_user.companies else 1) + cid = company_id or selected_company supplier = await SyncService.create_local_supplier( session, cid, data.name, data.fiscal_code, data.address, current_user.username @@ -136,10 +168,10 @@ async def create_local_supplier( async def get_cash_registers( company_id: Optional[int] = None, session: AsyncSession = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), + selected_company: SelectedCompany = None, ): """Get all cash registers for a company.""" - cid = company_id or (current_user.companies[0] if current_user.companies else 1) + cid = company_id or selected_company registers = await SyncService.get_all_cash_registers(session, cid) @@ -159,10 +191,10 @@ async def get_cash_registers( async def sync_suppliers( company_id: Optional[int] = None, session: AsyncSession = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), + selected_company: SelectedCompany = None, ): """Manually trigger supplier sync from Oracle.""" - cid = company_id or (current_user.companies[0] if current_user.companies else 1) + cid = company_id or selected_company synced, errors = await SyncService.sync_suppliers(session, cid) @@ -177,10 +209,10 @@ async def sync_suppliers( async def sync_cash_registers( company_id: Optional[int] = None, session: AsyncSession = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), + selected_company: SelectedCompany = None, ): """Manually trigger cash register sync from Oracle.""" - cid = company_id or (current_user.companies[0] if current_user.companies else 1) + cid = company_id or selected_company synced, errors = await SyncService.sync_cash_registers(session, cid) @@ -195,10 +227,10 @@ async def sync_cash_registers( async def sync_all_nomenclatures( company_id: Optional[int] = None, session: AsyncSession = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), + selected_company: SelectedCompany = None, ): """Sync all nomenclatures (suppliers + cash registers) from Oracle.""" - cid = company_id or (current_user.companies[0] if current_user.companies else 1) + cid = company_id or selected_company # Sync suppliers suppliers_synced, suppliers_errors = await SyncService.sync_suppliers(session, cid) diff --git a/data-entry-app/backend/app/routers/receipts.py b/data-entry-app/backend/app/routers/receipts.py index 25c4f12..cf128a3 100644 --- a/data-entry-app/backend/app/routers/receipts.py +++ b/data-entry-app/backend/app/routers/receipts.py @@ -1,9 +1,9 @@ """API endpoints for receipts.""" -from typing import List, Optional +from typing import List, Optional, Annotated from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Header from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -39,20 +39,69 @@ from auth.models import CurrentUser router = APIRouter() -# ============ Helper for current user's company ============ +# ============ Helper for selected company from header ============ +async def get_selected_company( + current_user: CurrentUser = Depends(get_current_user), + x_selected_company: Annotated[Optional[str], Header()] = None +) -> int: + """ + Get selected company from X-Selected-Company header. + + Validates that the user has access to the specified company. + Falls back to user's first company if no header is provided. + + Raises: + HTTPException 403: If user doesn't have access to specified company + HTTPException 400: If user has no companies assigned + """ + if x_selected_company: + try: + company_id = int(x_selected_company) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Invalid company ID format: {x_selected_company}" + ) + + # Validate user has access to this company + # Auth stores companies as strings + if str(company_id) in current_user.companies: + return company_id + + raise HTTPException( + status_code=403, + detail=f"Nu aveศ›i acces la firma {company_id}" + ) + + # No header - use first company from user's list + if current_user.companies: + try: + return int(current_user.companies[0]) + except (ValueError, IndexError): + pass + + raise HTTPException( + status_code=400, + detail="Nu aveศ›i nicio firmฤƒ asignatฤƒ" + ) + + +# Dependency for injection +SelectedCompany = Annotated[int, Depends(get_selected_company)] + + +# Legacy function for backwards compatibility (deprecated) def get_current_user_company(current_user: CurrentUser) -> int: """ - Get current user's active company. - - Returns the first company from the user's companies list. - In future, this can be enhanced to use a session-based active company. + DEPRECATED: Use get_selected_company() dependency instead. + This function returns the first company, ignoring X-Selected-Company header. """ if current_user.companies: - # For data-entry-app, we assume company ID is numeric - # If companies are stored as strings, convert to int - # For now, return 1 as default (Phase 1) - return 1 + try: + return int(current_user.companies[0]) + except (ValueError, IndexError): + return 1 return 1 @@ -80,16 +129,14 @@ async def list_receipts( page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), session: AsyncSession = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), + selected_company: SelectedCompany = None, ): """Get paginated list of receipts with filters.""" from datetime import date as date_type - current_company = get_current_user_company(current_user) - filters = ReceiptFilter( status=status, - company_id=company_id or current_company, + company_id=company_id or selected_company, created_by=created_by, date_from=date_type.fromisoformat(date_from) if date_from else None, date_to=date_type.fromisoformat(date_to) if date_to else None, @@ -105,12 +152,11 @@ async def list_receipts( async def list_pending_receipts( company_id: Optional[int] = None, session: AsyncSession = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), + selected_company: SelectedCompany = None, ): """Get all receipts pending review (for accountant view).""" - current_company = get_current_user_company(current_user) receipts = await ReceiptCRUD.get_pending_review( - session, company_id or current_company + session, company_id or selected_company ) return [ReceiptResponse.model_validate(r) for r in receipts] @@ -120,13 +166,13 @@ async def get_receipt_stats( company_id: Optional[int] = None, my_receipts: bool = False, session: AsyncSession = Depends(get_session), + selected_company: SelectedCompany = None, current_user: CurrentUser = Depends(get_current_user), ): """Get receipt statistics.""" - current_company = get_current_user_company(current_user) return await ReceiptCRUD.get_stats( session, - company_id or current_company, + company_id or selected_company, created_by=current_user.username if my_receipts else None, ) @@ -415,12 +461,11 @@ async def get_partners( search: Optional[str] = None, company_id: Optional[int] = None, session: AsyncSession = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), + selected_company: SelectedCompany = None, ): """Get partners (suppliers/customers) for dropdown.""" - current_company = get_current_user_company(current_user) return await NomenclatureService.get_partners( - company_id or current_company, search, session + company_id or selected_company, search, session ) @@ -428,12 +473,11 @@ async def get_partners( async def get_accounts( prefix: Optional[str] = None, company_id: Optional[int] = None, - current_user: CurrentUser = Depends(get_current_user), + selected_company: SelectedCompany = None, ): """Get chart of accounts for dropdown.""" - current_company = get_current_user_company(current_user) return await NomenclatureService.get_accounts( - company_id or current_company, prefix + company_id or selected_company, prefix ) @@ -441,11 +485,10 @@ async def get_accounts( async def get_cash_registers( company_id: Optional[int] = None, session: AsyncSession = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), + selected_company: SelectedCompany = None, ): """Get cash registers and bank accounts for dropdown.""" - current_company = get_current_user_company(current_user) - return await NomenclatureService.get_cash_registers(company_id or current_company, session) + return await NomenclatureService.get_cash_registers(company_id or selected_company, session) @router.get("/nomenclature/expense-types", response_model=List[ExpenseTypeOption]) diff --git a/data-entry-app/backend/app/services/sync_service.py b/data-entry-app/backend/app/services/sync_service.py index 7b99fcc..043f041 100644 --- a/data-entry-app/backend/app/services/sync_service.py +++ b/data-entry-app/backend/app/services/sync_service.py @@ -18,29 +18,54 @@ from app.db.models.nomenclature import SyncedSupplier, LocalSupplier, SyncedCash logger = logging.getLogger(__name__) -# Company ID to Oracle Schema mapping -# TODO: This should come from a config table or environment variable -COMPANY_SCHEMAS = { - 1: "CONTAFIN", # Example mapping - update with real schema names - 2: "CONTAFIN2", -} +# Cache for schema lookups (populated dynamically from Oracle) +_schema_cache: dict[int, str] = {} class SyncService: """Service for syncing nomenclatures from Oracle.""" @staticmethod - def get_schema_for_company(company_id: int) -> Optional[str]: - """Get Oracle schema for company ID.""" - return COMPANY_SCHEMAS.get(company_id) + async def get_schema_for_company(company_id: int) -> Optional[str]: + """ + Get Oracle schema for company ID from V_NOM_FIRME view. + Results are cached in memory for performance. + """ + # Check cache first + if company_id in _schema_cache: + return _schema_cache[company_id] + + try: + async with oracle_pool.get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute(""" + SELECT SCHEMA + FROM CONTAFIN_ORACLE.V_NOM_FIRME + WHERE ID_FIRMA = :company_id + """, {'company_id': company_id}) + result = cursor.fetchone() + + if result: + schema = result[0] + _schema_cache[company_id] = schema + logger.info(f"Resolved schema for company {company_id}: {schema}") + return schema + else: + logger.warning(f"No schema found for company {company_id}") + return None + + except Exception as e: + logger.error(f"Error fetching schema for company {company_id}: {e}") + return None @staticmethod async def sync_suppliers(session: AsyncSession, company_id: int) -> Tuple[int, int]: """ - Sync suppliers from Oracle NOM_PARTENERI to SQLite. + Sync suppliers (furnizori, id_tip_part=17) from Oracle to SQLite. + Uses CORESP_TIP_PART joined with VNOM_PARTENERI view. Returns (synced_count, error_count). """ - schema = SyncService.get_schema_for_company(company_id) + schema = await SyncService.get_schema_for_company(company_id) if not schema: logger.warning(f"No schema mapping for company {company_id}") return 0, 0 @@ -51,11 +76,17 @@ class SyncService: try: async with oracle_pool.get_connection() as connection: with connection.cursor() as cursor: - # Fetch active partners from Oracle + # Fetch active suppliers from Oracle + # id_tip_part = 17 means "furnizori" (suppliers) + # Using CORESP_TIP_PART to filter by partner type cursor.execute(f""" - SELECT ID_PART, DEN_PART, COD_FISCAL, ADRESA - FROM {schema}.NOM_PARTENERI - WHERE ACTIV = 1 + SELECT B.ID_PART, B.DENUMIRE, B.COD_FISCAL, B.ADRESA + FROM {schema}.CORESP_TIP_PART A + INNER JOIN {schema}.VNOM_PARTENERI B ON A.ID_PART = B.ID_PART + WHERE A.ID_TIP_PART = 17 + AND (B.INACTIV = 0 OR B.INACTIV IS NULL) + AND B.ID_PART IS NOT NULL + ORDER BY B.DENUMIRE """) rows = cursor.fetchall() @@ -110,10 +141,16 @@ class SyncService: @staticmethod async def sync_cash_registers(session: AsyncSession, company_id: int) -> Tuple[int, int]: """ - Sync cash registers from Oracle to SQLite. + Sync cash registers and bank accounts from Oracle to SQLite. Returns (synced_count, error_count). + + Uses CORESP_TIP_PART with: + - id_tip_part = 22: CASA LEI + - id_tip_part = 23: CASA VALUTA + - id_tip_part = 24: BANCA LEI + - id_tip_part = 25: BANCA VALUTA """ - schema = SyncService.get_schema_for_company(company_id) + schema = await SyncService.get_schema_for_company(company_id) if not schema: logger.warning(f"No schema mapping for company {company_id}") return 0, 0 @@ -121,25 +158,40 @@ class SyncService: synced = 0 errors = 0 + # Partner types mapping + # 22=CASA LEI, 23=CASA VALUTA -> cash + # 24=BANCA LEI, 25=BANCA VALUTA -> bank + partner_types = [22, 23, 24, 25] + try: async with oracle_pool.get_connection() as connection: with connection.cursor() as cursor: - # Fetch cash registers (both cash and bank) - # Assuming similar structure to NOM_PARTENERI - # TODO: Verify actual table name and structure in Oracle + # Fetch cash/bank partners from CORESP_TIP_PART cursor.execute(f""" - SELECT ID_CASA, DEN_CASA, CONT - FROM {schema}.NOM_CASE - WHERE ACTIV = 1 + SELECT B.ID_PART, B.DENUMIRE, A.ID_TIP_PART + FROM {schema}.CORESP_TIP_PART A + INNER JOIN {schema}.VNOM_PARTENERI B ON A.ID_PART = B.ID_PART + WHERE A.ID_TIP_PART IN (22, 23, 24, 25) + AND (B.INACTIV = 0 OR B.INACTIV IS NULL) + AND B.ID_PART IS NOT NULL + ORDER BY A.ID_TIP_PART, B.DENUMIRE """) rows = cursor.fetchall() + # Type mapping: 22=CASA LEI, 23=CASA VALUTA -> cash; 24=BANCA LEI, 25=BANCA VALUTA -> bank + type_mapping = { + 22: ("cash", "CASA_LEI"), + 23: ("cash", "CASA_VALUTA"), + 24: ("bank", "BANCA_LEI"), + 25: ("bank", "BANCA_VALUTA"), + } + for row in rows: try: - oracle_id, name, account_code = row + oracle_id, name, tip_part_id = row - # Determine type based on account code - register_type = "cash" if account_code.startswith("531") else "bank" + # Determine type based on partner type + register_type, account_code = type_mapping.get(tip_part_id, ("cash", "UNKNOWN")) # Check if already exists stmt = select(SyncedCashRegister).where( @@ -152,7 +204,7 @@ class SyncService: if existing: # Update existing record existing.name = name or "" - existing.account_code = account_code or "" + existing.account_code = account_code existing.register_type = register_type existing.synced_at = datetime.utcnow() logger.debug(f"Updated cash register {oracle_id}: {name}") @@ -162,7 +214,7 @@ class SyncService: oracle_id=oracle_id, company_id=company_id, name=name or "", - account_code=account_code or "", + account_code=account_code, register_type=register_type, ) session.add(cash_register) diff --git a/data-entry-app/backend/migrations/env.py b/data-entry-app/backend/migrations/env.py index 35781d3..8f8d2d0 100644 --- a/data-entry-app/backend/migrations/env.py +++ b/data-entry-app/backend/migrations/env.py @@ -1,6 +1,8 @@ """Alembic environment configuration.""" +import os from logging.config import fileConfig +from dotenv import load_dotenv from sqlalchemy import engine_from_config from sqlalchemy import pool @@ -8,14 +10,22 @@ from sqlalchemy import pool from alembic import context from sqlmodel import SQLModel +# Load environment variables from .env file +load_dotenv() + # Import all models to ensure they're registered with SQLModel from app.db.models.receipt import Receipt, ReceiptAttachment from app.db.models.accounting_entry import AccountingEntry +from app.db.models.nomenclature import SyncedSupplier, LocalSupplier, SyncedCashRegister # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config +# Override sqlalchemy.url from environment variable if set +db_path = os.getenv("SQLITE_DATABASE_PATH", "data/receipts.db") +config.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") + # Interpret the config file for Python logging. # This line sets up loggers basically. if config.config_file_name is not None: diff --git a/data-entry-app/docs/IMPLEMENTATION_PLAN_AUTH_EXEC.md b/data-entry-app/docs/IMPLEMENTATION_PLAN_AUTH_EXEC.md deleted file mode 100644 index 2dc53a1..0000000 --- a/data-entry-app/docs/IMPLEMENTATION_PLAN_AUTH_EXEC.md +++ /dev/null @@ -1,346 +0,0 @@ -# Plan: Implementare Auth SSO + Nomenclatoare Sync - -> **Plan Handover Document** - Salvat pentru continuare รฎn altฤƒ sesiune -> **Data**: 2025-12-13 | **Branch**: `feature/data-entry-receipts` - -## Obiectiv -Integrare autentificare SSO ศ™i sincronizare nomenclatoare Oracle รฎn data-entry-app conform `IMPLEMENTATION_PLAN_AUTH_UNITAR.md`. - ---- - -## Instrucศ›iuni Implementare - -### Metodologie -1. **Executฤƒ fazele รฎn paralel** unde e posibil (Faza 1+2 pot rula simultan, Faza 3+4 pot rula simultan) -2. **Foloseศ™te agenศ›i Task** pentru viteza - lanseazฤƒ agenศ›i รฎn paralel pentru task-uri independente -3. **Testeazฤƒ dupฤƒ fiecare fazฤƒ** - nu trece la urmฤƒtoarea fฤƒrฤƒ validare -4. **Urmฤƒreศ™te progresul** รฎn acest fiศ™ier - marcheazฤƒ task-urile completate cu โœ… - -### Comenzi de Start -```bash -# Asigurฤƒ-te cฤƒ SSH tunnel ruleazฤƒ (pentru Oracle) -./ssh_tunnel.sh start - -# Backend reports (pentru auth API - port 8001) -cd reports-app/backend && uvicorn app.main:app --reload --port 8001 - -# Backend data-entry (port 8003) -cd data-entry-app/backend && uvicorn app.main:app --reload --port 8003 - -# Frontend data-entry (port 3010) -cd data-entry-app/frontend && npm run dev -``` - -### Progres Implementare -- [x] **FAZA 1**: Auth Backend - โœ… 6/6 task-uri COMPLETE -- [x] **FAZA 2**: Auth Frontend - โœ… 6/6 task-uri COMPLETE -- [x] **FAZA 3**: Nomenclatoare Sync - โœ… 6/6 task-uri COMPLETE -- [x] **FAZA 4**: OCR + Supplier Search - โœ… 2/2 task-uri COMPLETE - -> **Status**: โœ… **IMPLEMENTARE COMPLETฤ‚** - 2025-12-13 - ---- - -## Stare Curentฤƒ (IMPLEMENTAT) - -### Backend Data-Entry โœ… -- โœ… Models: Receipt, ReceiptAttachment, AccountingEntry - complete -- โœ… CRUD operations - complete -- โœ… API Routers: receipts.py, ocr.py, **nomenclature.py** -- โœ… Services: receipt_service, ocr_service, **sync_service** -- โœ… Workflow: DRAFT โ†’ PENDING โ†’ APPROVED/REJECTED -- โœ… **Auth**: Integrare shared/auth (middleware + CurrentUser) -- โœ… **Nomenclatoare**: SQLite sync (SyncedSupplier, LocalSupplier, SyncedCashRegister) -- โœ… `sys.path.insert` pentru shared/ รฎn main.py - -### Frontend Data-Entry โœ… -- โœ… Views: List, Create, Detail, Approval, **LoginView** -- โœ… Components: OCR components + **Create Supplier Dialog** -- โœ… Store: receiptsStore.js + **auth.js** -- โœ… Router: routes + **auth guards + /login** -- โœ… **Auth Store**: `src/stores/auth.js` - creat -- โœ… **Login View**: `src/views/LoginView.vue` - creat -- โœ… **Router Guards**: beforeEach cu requiresAuth -- โœ… **API Service**: `src/services/api.js` - creat cu interceptors - -### Shared Auth (disponibil pentru integrare) -- โœ… `shared/auth/routes.py` - `create_auth_router()` (linia 39-430) -- โœ… `shared/auth/middleware.py` - `AuthenticationMiddleware` -- โœ… `shared/auth/dependencies.py` - `get_current_user` -- โœ… `shared/auth/models.py` - `CurrentUser`, `TokenResponse` - -### Referinศ›ฤƒ Reports-App (pentru copiere) -- `reports-app/frontend/src/stores/auth.js` - 119 linii -- `reports-app/frontend/src/services/api.js` - 141 linii -- `reports-app/frontend/src/views/LoginView.vue` - 367 linii -- `reports-app/frontend/src/router/index.js` - auth guard la liniile 96-114 - ---- - -## Faze Implementare - -### FAZA 1: Auth Backend (6 task-uri) - -#### Task 1.1: Adaugฤƒ AuthenticationMiddleware รฎn main.py -**Fiศ™ier**: `data-entry-app/backend/app/main.py` -**Acศ›iune**: Dupฤƒ CORS middleware (linia 75), adaugฤƒ: -```python -from auth.middleware import AuthenticationMiddleware -app.add_middleware( - AuthenticationMiddleware, - excluded_paths=["/docs", "/redoc", "/openapi.json", "/health", "/", "/api/auth/login", "/api/auth/refresh"] -) -``` - -#### Task 1.2: Adaugฤƒ Auth Router รฎn main.py -**Fiศ™ier**: `data-entry-app/backend/app/main.py` -**Acศ›iune**: Dupฤƒ include_router pentru ocr (linia 98), adaugฤƒ: -```python -from auth.routes import create_auth_router -auth_router = create_auth_router() -app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) -``` - -#### Task 1.3: รŽnlocuieศ™te get_current_user รฎn receipts.py -**Fiศ™ier**: `data-entry-app/backend/app/routers/receipts.py` -**Acศ›iune**: ศ˜terge liniile 38-59 ศ™i รฎnlocuieศ™te cu: -```python -from auth.dependencies import get_current_user -from auth.models import CurrentUser -``` -Apoi actualizeazฤƒ type hints: `current_user: str` โ†’ `current_user: CurrentUser` -ศ˜i acceseazฤƒ `current_user.username` รฎn loc de `current_user` - -#### Task 1.4: รŽnlocuieศ™te get_current_user รฎn ocr.py -**Fiศ™ier**: `data-entry-app/backend/app/routers/ocr.py` -**Acศ›iune**: Similar cu receipts.py, adaugฤƒ importurile auth ศ™i foloseศ™te `CurrentUser` - -#### Task 1.5: Actualizeazฤƒ type hints รฎn toate endpoint-urile -Actualizeazฤƒ toate funcศ›iile care folosesc `current_user: str` sฤƒ foloseascฤƒ `current_user: CurrentUser` - -#### Task 1.6: Testare backend auth -```bash -cd data-entry-app/backend -uvicorn app.main:app --reload --port 8003 -# Test: curl http://localhost:8003/api/receipts/ โ†’ 401 Unauthorized -``` - ---- - -### FAZA 2: Auth Frontend (6 task-uri) - -#### Task 2.1: Creazฤƒ API service -**Fiศ™ier NOU**: `data-entry-app/frontend/src/services/api.js` -**Acศ›iune**: Copiazฤƒ din `reports-app/frontend/src/services/api.js` cu modificฤƒri: -- Schimbฤƒ BASE_URL pentru a funcศ›iona cu proxy-ul -- Modificฤƒ refresh token URL - -#### Task 2.2: Creazฤƒ Auth Store -**Fiศ™ier NOU**: `data-entry-app/frontend/src/stores/auth.js` -**Acศ›iune**: Copiazฤƒ din `reports-app/frontend/src/stores/auth.js` -- Modificฤƒ import apiService din `../services/api` - -#### Task 2.3: Creazฤƒ LoginView -**Fiศ™ier NOU**: `data-entry-app/frontend/src/views/LoginView.vue` -**Acศ›iune**: Copiazฤƒ din `reports-app/frontend/src/views/LoginView.vue` -- Schimbฤƒ titlul: "ROA Reports" โ†’ "Data Entry" -- Schimbฤƒ subtitle: "Rapoarte ERP" โ†’ "Introducere Bonuri Fiscale" -- Schimbฤƒ redirect dupฤƒ login: "/dashboard" โ†’ "/" - -#### Task 2.4: Actualizeazฤƒ Router cu auth guards -**Fiศ™ier**: `data-entry-app/frontend/src/router/index.js` -**Acศ›iune**: Adaugฤƒ auth guard similar cu reports-app (liniile 96-114) -```javascript -import { useAuthStore } from '@/stores/auth' -// Adaugฤƒ rutฤƒ login -// Adaugฤƒ meta: { requiresAuth: true } la rutele protejate -// Adaugฤƒ beforeEach guard -``` - -#### Task 2.5: Actualizeazฤƒ vite.config.js pentru auth proxy -**Fiศ™ier**: `data-entry-app/frontend/vite.config.js` -**Acศ›iune**: Adaugฤƒ proxy pentru auth: -```javascript -'/api/auth': { - target: 'http://localhost:8001', - changeOrigin: true, -} -``` - -#### Task 2.6: Testare frontend auth -```bash -cd data-entry-app/frontend -npm run dev -# Test: Acceseazฤƒ http://localhost:3010 โ†’ Redirect la /login -# Login cu credenศ›iale Oracle โ†’ Redirect la / -``` - ---- - -### FAZA 3: Nomenclatoare Oracleโ†’SQLite (6 task-uri) - -#### Task 3.1: Creazฤƒ modele SQLModel -**Fiศ™ier NOU**: `data-entry-app/backend/app/db/models/nomenclature.py` -- `SyncedSupplier` - furnizori sincronizaศ›i din Oracle -- `LocalSupplier` - furnizori creaศ›i local (din OCR) -- `SyncedCashRegister` - case/bฤƒnci sincronizate - -#### Task 3.2: Creazฤƒ Alembic migration -```bash -cd data-entry-app/backend -alembic revision --autogenerate -m "add nomenclature tables" -alembic upgrade head -``` - -#### Task 3.3: Creazฤƒ Sync Service -**Fiศ™ier NOU**: `data-entry-app/backend/app/services/sync_service.py` -- `sync_suppliers(company_id, schema)` - sync furnizori Oracleโ†’SQLite -- `sync_cash_registers(company_id, schema)` - sync case/bฤƒnci -- `get_schema_for_company(company_id)` - lookup schema - -#### Task 3.4: Creazฤƒ Nomenclature Router -**Fiศ™ier NOU**: `data-entry-app/backend/app/routers/nomenclature.py` -- `GET /suppliers/search` - cฤƒutare furnizor (SQLite + Oracle live) -- `POST /suppliers/local` - creare furnizor local -- `POST /sync/suppliers` - trigger manual sync - -#### Task 3.5: รŽnregistreazฤƒ router รฎn main.py -```python -from app.routers import nomenclature -app.include_router(nomenclature.router, prefix="/api/nomenclature", tags=["nomenclature"]) -``` - -#### Task 3.6: Actualizare nomenclature_service.py existent -รŽnlocuieศ™te mock data cu query-uri din tabelele SQLite sincronizate - ---- - -### FAZA 4: Integrare OCR + Supplier Search (2 task-uri) - -#### Task 4.1: Actualizare ReceiptCreateView.vue -**Fiศ™ier**: `data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue` -**Acศ›iune**: Dupฤƒ OCR result, cautฤƒ automat furnizor dupฤƒ CUI: -```javascript -async function handleOCRResult(ocrData) { - if (ocrData.cui) { - const result = await receiptsStore.searchSupplier(ocrData.cui); - if (result.found) { - form.partner_id = result.supplier.id; - form.partner_name = result.supplier.name; - } else { - showCreateSupplierDialog(ocrData); - } - } -} -``` - -#### Task 4.2: Adaugฤƒ supplier search รฎn receiptsStore.js -**Fiศ™ier**: `data-entry-app/frontend/src/stores/receiptsStore.js` -**Acศ›iune**: Adaugฤƒ action `searchSupplier(fiscalCode)` ศ™i `createLocalSupplier(data)` - ---- - -## Sumar Fiศ™iere - -### De Modificat -| Fiศ™ier | Faza | -|--------|------| -| `data-entry-app/backend/app/main.py` | 1, 3 | -| `data-entry-app/backend/app/routers/receipts.py` | 1 | -| `data-entry-app/backend/app/routers/ocr.py` | 1 | -| `data-entry-app/frontend/src/router/index.js` | 2 | -| `data-entry-app/frontend/vite.config.js` | 2 | -| `data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue` | 4 | -| `data-entry-app/frontend/src/stores/receiptsStore.js` | 4 | - -### De Creat (NOU) -| Fiศ™ier | Faza | -|--------|------| -| `data-entry-app/frontend/src/services/api.js` | 2 | -| `data-entry-app/frontend/src/stores/auth.js` | 2 | -| `data-entry-app/frontend/src/views/LoginView.vue` | 2 | -| `data-entry-app/backend/app/db/models/nomenclature.py` | 3 | -| `data-entry-app/backend/app/services/sync_service.py` | 3 | -| `data-entry-app/backend/app/routers/nomenclature.py` | 3 | -| `migrations/versions/xxx_nomenclature.py` | 3 | - ---- - -## Ordine Execuศ›ie - -**Faza 1 + 2 (Auth)** โ†’ **Faza 3 + 4 (Nomenclatoare)** - -Fazele 1-2 sunt blocante pentru funcศ›ionalitatea completฤƒ, dar Faza 3-4 poate fi amรขnatฤƒ dacฤƒ e nevoie (nomenclatoarele rฤƒmรขn mock data temporar). - ---- - -## Strategie Execuศ›ie cu Agenศ›i - -### Agenศ›i Paraleli Recomandaศ›i - -**Round 1 - Auth (Backend + Frontend simultan):** -``` -Agent A: Faza 1 - Task 1.1-1.5 (Backend auth) -Agent B: Faza 2 - Task 2.1-2.3 (Frontend auth files) -``` -Dupฤƒ Round 1, testare manualฤƒ auth flow. - -**Round 2 - Finalizare Auth + Start Nomenclatoare:** -``` -Agent A: Faza 2 - Task 2.4-2.5 (Router guards, vite config) -Agent B: Faza 3 - Task 3.1-3.2 (Modele SQLModel + migration) -``` - -**Round 3 - Nomenclatoare + Integration:** -``` -Agent A: Faza 3 - Task 3.3-3.6 (Sync service + router) -Agent B: Faza 4 - Task 4.1-4.2 (Frontend OCR supplier) -``` - -### Validare Dupฤƒ Fiecare Fazฤƒ - -**Dupฤƒ Faza 1:** -```bash -curl http://localhost:8003/api/receipts/ -# Expected: 401 Unauthorized -``` - -**Dupฤƒ Faza 2:** -```bash -# Browser: http://localhost:3010 -# Expected: Redirect to /login -# Login cu credenศ›iale Oracle โ†’ Redirect la / -``` - -**Dupฤƒ Faza 3:** -```bash -curl http://localhost:8003/api/nomenclature/suppliers/search?fiscal_code=RO12345678 -# Expected: Search result sau sugestie creare local -``` - -**Dupฤƒ Faza 4:** -``` -# Browser: Creazฤƒ bon nou โ†’ Upload pozฤƒ โ†’ OCR -# Expected: Furnizor gฤƒsit automat sau dialog creare -``` - ---- - -## Context pentru Sesiune Urmฤƒtoare - -### Fiศ™iere Cheie de Citit -1. Acest plan: `/home/marius/.claude/plans/unified-orbiting-sonnet.md` -2. CLAUDE.md principal: `/mnt/e/proiecte/roa2web/CLAUDE.md` -3. CLAUDE.md data-entry: `/mnt/e/proiecte/roa2web/data-entry-app/CLAUDE.md` - -### Comenzi Quick Start -```bash -cd /mnt/e/proiecte/roa2web -git status # Verificฤƒ branch feature/data-entry-receipts -./ssh_tunnel.sh start # SSH tunnel pentru Oracle -``` - -### Dependenศ›e Servicii -- **reports-backend:8001** - NECESAR pentru auth API (login, refresh) -- **data-entry-backend:8003** - Backend principal -- **Oracle DB** - Via SSH tunnel, necesar pentru auth + nomenclatoare diff --git a/data-entry-app/docs/IMPLEMENTATION_PLAN_AUTH_UNITAR.md b/data-entry-app/docs/IMPLEMENTATION_PLAN_AUTH_UNITAR.md deleted file mode 100644 index 25ac284..0000000 --- a/data-entry-app/docs/IMPLEMENTATION_PLAN_AUTH_UNITAR.md +++ /dev/null @@ -1,484 +0,0 @@ -# Plan: Sincronizare Nomenclatoare Oracle + Auth SSO + OCR Furnizori - -## Obiective -1. **Sincronizare nomenclatoare din Oracle รฎn SQLite** (furnizori, casa/banca) -2. **Auth pentru data-entry-app** cu SSO (frontend-uri separate pe path) -3. **OCR: cฤƒutare furnizor dupฤƒ CUI** + creare localฤƒ dacฤƒ nu existฤƒ -4. **Deploy Windows IIS** cu path routing - ---- - -## Arhitectura Aleasฤƒ - -``` -roa2web.romfast.ro (IIS + ARR) -โ”‚ -โ”œโ”€โ”€ /reports/ โ†’ reports-app/frontend/ -โ”œโ”€โ”€ /data/ โ†’ data-entry-app/frontend/ -โ”‚ -โ”œโ”€โ”€ /api/reports/* โ†’ reports-backend:8001 -โ”œโ”€โ”€ /api/data/* โ†’ data-entry-backend:8003 -โ””โ”€โ”€ /api/auth/* โ†’ reports-backend (auth provider) -``` - -**URL-uri compacte:** -- `roa2web.romfast.ro/reports/` - Rapoarte -- `roa2web.romfast.ro/data/` - Introducere date (bonuri fiscale) -- `roa2web.romfast.ro/api/reports/` - API rapoarte -- `roa2web.romfast.ro/api/data/` - API introducere date - -**SSO**: Acelaศ™i domeniu = localStorage partajat = token JWT valid pentru ambele - ---- - -## Faza 1: Auth pentru Data-Entry-App - -### 1.1 Backend - Integrare shared/auth/ - -**Fiศ™iere de modificat:** -- `data-entry-app/backend/app/main.py` -- `data-entry-app/backend/app/routers/receipts.py` -- `data-entry-app/backend/app/core/config.py` - -**Acศ›iuni:** -```python -# main.py - Adฤƒugare middleware -import sys -sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent / "shared")) - -from auth.middleware import AuthenticationMiddleware -from auth.dependencies import get_current_user - -app.add_middleware( - AuthenticationMiddleware, - excluded_paths=["/docs", "/redoc", "/openapi.json", "/health", "/"] -) -``` - -```python -# receipts.py - รŽnlocuire placeholder -from auth.dependencies import get_current_user -from auth.models import CurrentUser - -@router.get("/") -async def list_receipts( - current_user: CurrentUser = Depends(get_current_user) -): - # foloseศ™te current_user.username -``` - -### 1.2 Frontend - Auth Store + Login Page - -**Fiศ™iere de creat/copiat din reports-app:** -- `data-entry-app/frontend/src/stores/auth.js` (copiat) -- `data-entry-app/frontend/src/views/LoginView.vue` (copiat) -- `data-entry-app/frontend/src/router/index.js` (adฤƒugat guard) -- `data-entry-app/frontend/src/services/api.js` (axios interceptor) - -**Decizie SSO:** -- Frontend data-entry foloseศ™te `/api/auth/login` de pe reports-backend -- Sau: redirect la `/login` (reports-app) care seteazฤƒ token รฎn localStorage -- Token valid pentru ambele (acelaศ™i JWT_SECRET_KEY) - ---- - -## Faza 2: Sincronizare Nomenclatoare Oracle โ†’ SQLite - -### 2.1 Noi Modele SQLModel - -**Fiศ™ier:** `data-entry-app/backend/app/db/models/nomenclature.py` - -```python -class SyncedSupplier(SQLModel, table=True): - """Furnizori sincronizaศ›i din Oracle""" - __tablename__ = "synced_suppliers" - - id: int = Field(primary_key=True) # ID din Oracle (ID_PART) - company_id: int = Field(index=True) - name: str = Field(max_length=200) # DEN_PART - fiscal_code: Optional[str] = Field(max_length=20, index=True) # COD_FISCAL - address: Optional[str] = Field(max_length=500) - synced_at: datetime = Field(default_factory=datetime.utcnow) - -class LocalSupplier(SQLModel, table=True): - """Furnizori creaศ›i local din OCR (neexistenศ›i รฎn Oracle)""" - __tablename__ = "local_suppliers" - - id: Optional[int] = Field(default=None, primary_key=True) - company_id: int = Field(index=True) - name: str = Field(max_length=200) - fiscal_code: str = Field(max_length=20, unique=True, index=True) - address: Optional[str] = Field(max_length=500) - created_by: str = Field(max_length=100) - created_at: datetime = Field(default_factory=datetime.utcnow) - oracle_synced: bool = Field(default=False) # True cรขnd e creat รฎn Oracle - -class SyncedCashRegister(SQLModel, table=True): - """Case/Bฤƒnci sincronizate din Oracle""" - __tablename__ = "synced_cash_registers" - - id: int = Field(primary_key=True) # ID din Oracle - company_id: int = Field(index=True) - name: str = Field(max_length=100) - account_code: str = Field(max_length=20) # 5311, 5121 etc. - register_type: str = Field(max_length=20) # CASA sau BANCA - synced_at: datetime = Field(default_factory=datetime.utcnow) -``` - -### 2.2 Alembic Migration - -**Fiศ™ier:** `data-entry-app/backend/migrations/versions/xxx_add_nomenclature_tables.py` - -### 2.3 Sync Service - -**Fiศ™ier:** `data-entry-app/backend/app/services/sync_service.py` - -```python -class NomenclatureSyncService: - """Sincronizare nomenclatoare din Oracle รฎn SQLite""" - - @staticmethod - async def sync_suppliers(company_id: int, schema: str) -> int: - """Sincronizeazฤƒ furnizori pentru o companie""" - async with oracle_pool.get_connection() as conn: - cursor = conn.cursor() - cursor.execute(f""" - SELECT ID_PART, DEN_PART, COD_FISCAL, ADRESA - FROM {schema}.NOM_PARTENERI - WHERE TIP_PART IN ('F', 'A') -- Furnizori sau Ambele - """) - # Upsert รฎn SQLite - - @staticmethod - async def sync_cash_registers(company_id: int, schema: str) -> int: - """Sincronizeazฤƒ case ศ™i bฤƒnci""" - # Similar pentru NOM_CASE ศ™i NOM_BANCI - - @staticmethod - async def get_schema_for_company(company_id: int) -> str: - """Obศ›ine schema Oracle pentru o companie""" - # Foloseศ™te cache din shared sau query V_NOM_FIRME -``` - -### 2.4 Strategia de Sync Hibrid - -1. **La startup app**: Sync automat (background task) -2. **Periodic**: Task programat la 4h -3. **On-demand**: Cฤƒutare live รฎn Oracle cรขnd CUI nu existฤƒ local - -**Fiศ™ier:** `data-entry-app/backend/app/main.py` -```python -@app.on_event("startup") -async def startup_sync(): - # Background sync pentru company-urile active - asyncio.create_task(sync_nomenclatures_background()) -``` - ---- - -## Faza 3: OCR + Cฤƒutare Furnizor dupฤƒ CUI - -### 3.1 Flow Cฤƒutare Furnizor - -``` -OCR extrage CUI - โ†“ -Cฤƒutare รฎn SyncedSupplier (SQLite) - โ†“ (nu gฤƒsit) -Cฤƒutare รฎn LocalSupplier (SQLite) - โ†“ (nu gฤƒsit) -Cฤƒutare LIVE รฎn Oracle (NOM_PARTENERI) - โ†“ (nu gฤƒsit) -Creare LocalSupplier cu date OCR - โ†“ -Utilizator poate edita รฎnainte de submit -``` - -### 3.2 Endpoint Cฤƒutare Furnizor - -**Fiศ™ier:** `data-entry-app/backend/app/routers/nomenclature.py` - -```python -@router.get("/suppliers/search") -async def search_supplier( - company_id: int, - fiscal_code: Optional[str] = None, - name: Optional[str] = None, - current_user: CurrentUser = Depends(get_current_user) -) -> SupplierSearchResult: - """ - Cautฤƒ furnizor: - 1. รŽn SQLite (synced + local) - 2. Live รฎn Oracle dacฤƒ nu gฤƒsit - 3. Returneazฤƒ sugestie creare dacฤƒ nu existฤƒ - """ - -@router.post("/suppliers/local") -async def create_local_supplier( - supplier: LocalSupplierCreate, - current_user: CurrentUser = Depends(get_current_user) -) -> LocalSupplier: - """Creazฤƒ furnizor local din date OCR""" -``` - -### 3.3 Modificare OCR Flow รฎn Frontend - -**Fiศ™ier:** `data-entry-app/frontend/src/views/ReceiptCreateView.vue` - -```javascript -// Dupฤƒ OCR, cautฤƒ automat furnizor -async function handleOCRResult(ocrData) { - if (ocrData.cui) { - const result = await api.get('/api/data-entry/suppliers/search', { - params: { company_id: selectedCompany.id, fiscal_code: ocrData.cui } - }); - - if (result.found) { - form.partner_id = result.supplier.id; - form.partner_name = result.supplier.name; - } else { - // Afiศ™eazฤƒ opศ›iune creare localฤƒ - showCreateSupplierDialog(ocrData); - } - } -} -``` - ---- - -## Faza 4: Deploy Windows IIS - -### 4.1 Serviciu Windows pentru data-entry-backend - -**Fiศ™ier:** `deployment/windows/scripts/Install-DataEntry.ps1` - -Similar cu Install-ROA2WEB.ps1 dar: -- ServiceName: `ROA2WEB-DataEntry` -- Port: 8003 -- BackendPath: `C:\inetpub\wwwroot\roa2web\data-entry-app\backend` -- FrontendPath: `C:\inetpub\wwwroot\roa2web\data-entry-app\frontend` - -**Actualizare Install-ROA2WEB.ps1** pentru structura unitarฤƒ: -- BackendPath: `C:\inetpub\wwwroot\roa2web\reports-app\backend` -- FrontendPath: `C:\inetpub\wwwroot\roa2web\reports-app\frontend` - -### 4.2 Actualizare web.config - -**Fiศ™ier:** `deployment/windows/config/web.config` - -Reguli URL compacte (`/reports/`, `/data/`, `/api/reports/`, `/api/data/`): - -```xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -``` - -**IIS Virtual Directories (pentru URL-uri compacte):** -```powershell -# /reports/ โ†’ reports-app/frontend/ -New-WebVirtualDirectory -Site "Default Web Site" -Name "reports" ` - -PhysicalPath "C:\inetpub\wwwroot\roa2web\reports-app\frontend" - -# /data/ โ†’ data-entry-app/frontend/ -New-WebVirtualDirectory -Site "Default Web Site" -Name "data" ` - -PhysicalPath "C:\inetpub\wwwroot\roa2web\data-entry-app\frontend" -``` - -### 4.3 Structura Foldere (UNITARฤ‚ - identicฤƒ dev/prod) - -**รŽn development (git repo):** -``` -roa2web/ -โ”œโ”€โ”€ reports-app/ -โ”‚ โ”œโ”€โ”€ backend/ # FastAPI port 8001 -โ”‚ โ”œโ”€โ”€ frontend/ # Vue.js port 3000 -โ”‚ โ””โ”€โ”€ telegram-bot/ # Bot Telegram -โ”œโ”€โ”€ data-entry-app/ -โ”‚ โ”œโ”€โ”€ backend/ # FastAPI port 8003 -โ”‚ โ””โ”€โ”€ frontend/ # Vue.js port 3010 -โ””โ”€โ”€ shared/ # Cod partajat (auth, database) -``` - -**รŽn producศ›ie (Windows IIS) - IDENTIC:** -``` -C:\inetpub\wwwroot\roa2web\ -โ”œโ”€โ”€ reports-app/ -โ”‚ โ”œโ”€โ”€ backend/ # Serviciu Windows port 8001 -โ”‚ โ””โ”€โ”€ frontend/ # Servit de IIS pe / -โ”œโ”€โ”€ data-entry-app/ -โ”‚ โ”œโ”€โ”€ backend/ # Serviciu Windows port 8003 -โ”‚ โ””โ”€โ”€ frontend/ # Servit de IIS pe /data-entry/ -โ”œโ”€โ”€ telegram-bot/ # Serviciu Windows port 8002 -โ””โ”€โ”€ shared/ # Cod partajat -``` - -**Avantaje structurฤƒ unitarฤƒ:** -- Deploy simplu: `xcopy /E /Y source\reports-app dest\reports-app` -- Path-uri identice รฎn cod (no surprises) -- Un singur script de deploy pentru ambele medii - ---- - -## Faza 5: Configurare Dev (identic cu prod) - -### 5.1 Vite Config pentru URL-uri Compacte - -**Fiศ™ier:** `data-entry-app/frontend/vite.config.js` -```javascript -export default defineConfig({ - base: '/data/', // URL compact รฎn producศ›ie - server: { - proxy: { - '/api/auth': 'http://localhost:8001', - '/api/data': 'http://localhost:8003' - } - } -}) -``` - -**Fiศ™ier:** `reports-app/frontend/vite.config.js` (ACTUALIZAT) -```javascript -export default defineConfig({ - base: '/reports/', // URL compact รฎn producศ›ie (era '/') - server: { - proxy: { - '/api/auth': 'http://localhost:8001', - '/api/reports': 'http://localhost:8001' - } - } -}) -``` - -**IMPORTANT:** Actualizare API calls รฎn frontend: -- Reports: `/api/reports/companies`, `/api/reports/invoices`, etc. -- Data Entry: `/api/data/receipts`, `/api/data/suppliers`, etc. -- Auth (comun): `/api/auth/login`, `/api/auth/refresh` - -### 5.2 Script Start Unificat - -**Fiศ™ier:** `start-all.sh` (nou) - -```bash -#!/bin/bash -# Porneศ™te toate serviciile pentru dev - -# SSH tunnel -./ssh_tunnel.sh start - -# Reports backend -cd reports-app/backend && uvicorn app.main:app --port 8001 & - -# Data entry backend -cd data-entry-app/backend && uvicorn app.main:app --port 8003 & - -# Reports frontend -cd reports-app/frontend && npm run dev -- --port 3000 & - -# Data entry frontend -cd data-entry-app/frontend && npm run dev -- --port 3010 & - -wait -``` - ---- - -## Ordine Implementare - -| # | Task | Efort | Dependenศ›e | -|---|------|-------|------------| -| 1 | Modele SQLModel nomenclatoare | 30 min | - | -| 2 | Alembic migration | 15 min | #1 | -| 3 | Sync service (Oracle โ†’ SQLite) | 2h | #2 | -| 4 | Auth middleware รฎn data-entry-backend | 1h | - | -| 5 | Auth store + login รฎn data-entry-frontend | 1h | #4 | -| 6 | Endpoint cฤƒutare furnizor | 1h | #3 | -| 7 | Frontend OCR + furnizor flow | 1.5h | #6 | -| 8 | web.config IIS actualizat | 30 min | - | -| 9 | Script deploy data-entry Windows | 1h | #8 | -| 10 | Testare end-to-end | 1h | all | - -**Total estimat: ~10h** - ---- - -## Fiศ™iere Critice de Modificat/Creat - -### Backend data-entry-app: -- `app/main.py` - middleware auth + startup sync -- `app/db/models/nomenclature.py` - noi modele (CREARE) -- `app/services/sync_service.py` - sync Oracle (CREARE) -- `app/services/nomenclature_service.py` - refactorizare -- `app/routers/nomenclature.py` - endpoint-uri noi (CREARE) -- `app/routers/receipts.py` - auth dependencies -- `migrations/versions/xxx_nomenclature.py` - migrare (CREARE) - -### Frontend data-entry-app: -- `src/stores/auth.js` - copiat din reports-app -- `src/views/LoginView.vue` - copiat + adaptat -- `src/router/index.js` - auth guard -- `src/services/api.js` - axios config -- `src/views/ReceiptCreateView.vue` - OCR + supplier flow - -### Deploy (structurฤƒ unitarฤƒ): -- `deployment/windows/config/web.config` - reguli noi + actualizate -- `deployment/windows/scripts/Install-ROA2WEB.ps1` - ACTUALIZAT pentru structura unitarฤƒ -- `deployment/windows/scripts/Install-DataEntry.ps1` - NOU -- `deployment/windows/scripts/Build-ROA2WEB.ps1` - ACTUALIZAT pentru ambele apps -- `deployment/windows/docs/WINDOWS_DEPLOYMENT.md` - ACTUALIZAT cu noua structurฤƒ - -### Shared: -- Nu necesitฤƒ modificฤƒri (refolosim exact ce existฤƒ) - ---- - -## รŽntrebฤƒri Rezolvate - -| รŽntrebare | Rฤƒspuns | -|-----------|---------| -| Furnizor nou din OCR? | Creare automatฤƒ รฎn SQLite (LocalSupplier) | -| Sync strategy? | Hibrid: startup + periodic 4h + on-demand | -| Auth sharing? | Frontend-uri separate pe path, acelaศ™i token JWT (SSO via localStorage) | -| Deployment? | IIS path routing, servicii Windows separate | -| Structura directoare? | **UNITARฤ‚** - grupat pe app (`{app}/backend`, `{app}/frontend`) identic dev/prod | -| SSO cum funcศ›ioneazฤƒ? | Acelaศ™i domeniu IIS โ†’ localStorage partajat โ†’ token valid pentru ambele API-uri | -| URL-uri? | **COMPACTE**: `/reports/`, `/data/`, `/api/reports/`, `/api/data/` | -| Root (/)? | Redirect automat la `/reports/` | diff --git a/data-entry-app/frontend/src/App.vue b/data-entry-app/frontend/src/App.vue index 5854e59..de1f1b2 100644 --- a/data-entry-app/frontend/src/App.vue +++ b/data-entry-app/frontend/src/App.vue @@ -1,38 +1,33 @@ diff --git a/shared/frontend/components/PeriodSelector.vue b/shared/frontend/components/PeriodSelector.vue new file mode 100644 index 0000000..5b4c24e --- /dev/null +++ b/shared/frontend/components/PeriodSelector.vue @@ -0,0 +1,441 @@ + + + + + diff --git a/shared/frontend/components/layout/AppHeader.vue b/shared/frontend/components/layout/AppHeader.vue new file mode 100644 index 0000000..a71bc70 --- /dev/null +++ b/shared/frontend/components/layout/AppHeader.vue @@ -0,0 +1,132 @@ + + + diff --git a/shared/frontend/components/layout/SlideMenu.vue b/shared/frontend/components/layout/SlideMenu.vue new file mode 100644 index 0000000..edb2689 --- /dev/null +++ b/shared/frontend/components/layout/SlideMenu.vue @@ -0,0 +1,101 @@ + + + diff --git a/shared/frontend/stores/accountingPeriod.js b/shared/frontend/stores/accountingPeriod.js new file mode 100644 index 0000000..7470e45 --- /dev/null +++ b/shared/frontend/stores/accountingPeriod.js @@ -0,0 +1,158 @@ +/** + * Shared Accounting Period Store Factory + * + * Creates a Pinia store for accounting period selection that can be used by any ROA2WEB application. + * Each app passes its own apiService and store references. + * + * Usage: + * import { createAccountingPeriodStore } from '@shared/frontend/stores/accountingPeriod'; + * import { apiService } from '../services/api'; + * import { useAuthStore } from './auth'; + * import { useCompanyStore } from './companies'; + * export const useAccountingPeriodStore = createAccountingPeriodStore(apiService, useAuthStore, useCompanyStore); + */ + +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; + +/** + * Factory function to create an accounting period store + * @param {Object} apiService - Axios instance configured for the app's API + * @param {Function} useAuthStore - Reference to the auth store function + * @param {Function} useCompanyStore - Reference to the company store function + * @returns {Function} Pinia store definition + */ +export function createAccountingPeriodStore(apiService, useAuthStore, useCompanyStore) { + return defineStore("accountingPeriod", () => { + // State + const periods = ref([]); + const selectedPeriod = ref(null); + const isLoading = ref(false); + const error = ref(null); + + // Getters + const hasPeriods = computed(() => periods.value.length > 0); + const currentPeriod = computed(() => selectedPeriod.value); + + // Computed date range for current period (first/last day of month) + const dateRange = computed(() => { + if (!selectedPeriod.value) return { dateFrom: null, dateTo: null }; + + const { an, luna } = selectedPeriod.value; + const firstDay = new Date(an, luna - 1, 1); + const lastDay = new Date(an, luna, 0); + + return { + dateFrom: firstDay, + dateTo: lastDay, + }; + }); + + // localStorage helpers + const getStorageKey = () => { + const authStore = useAuthStore(); + const companyStore = useCompanyStore(); + const username = authStore.user?.username; + const companyId = companyStore.selectedCompany?.id_firma; + if (!username || !companyId) return null; + return `selected_period_${username}_${companyId}`; + }; + + const initializeSelectedPeriod = () => { + const key = getStorageKey(); + if (!key) return null; + + const saved = localStorage.getItem(key); + if (saved) { + try { + return JSON.parse(saved); + } catch (e) { + localStorage.removeItem(key); + } + } + return null; + }; + + const persistSelectedPeriod = (period) => { + const key = getStorageKey(); + if (key && period) { + localStorage.setItem(key, JSON.stringify(period)); + } + }; + + // Actions + const loadPeriods = async (companyId) => { + if (!companyId) return { success: false }; + + isLoading.value = true; + error.value = null; + + try { + const response = await apiService.get("/calendar/periods", { + params: { company: companyId }, + }); + + periods.value = response.data.periods || []; + + // Try to restore saved period or use most recent + const saved = initializeSelectedPeriod(); + if (saved) { + const exists = periods.value.find( + (p) => p.an === saved.an && p.luna === saved.luna + ); + if (exists) { + selectedPeriod.value = exists; + } else if (response.data.current_period) { + setSelectedPeriod(response.data.current_period); + } + } else if (response.data.current_period) { + setSelectedPeriod(response.data.current_period); + } + + return { success: true }; + } catch (err) { + error.value = err.response?.data?.detail || "Failed to load periods"; + return { success: false, error: error.value }; + } finally { + isLoading.value = false; + } + }; + + const setSelectedPeriod = (period) => { + selectedPeriod.value = period; + persistSelectedPeriod(period); + }; + + const resetToLatest = () => { + if (periods.value.length > 0) { + setSelectedPeriod(periods.value[0]); + } + }; + + const reset = () => { + periods.value = []; + selectedPeriod.value = null; + isLoading.value = false; + error.value = null; + }; + + return { + // State + periods, + selectedPeriod, + isLoading, + error, + + // Getters + hasPeriods, + currentPeriod, + dateRange, + + // Actions + loadPeriods, + setSelectedPeriod, + resetToLatest, + reset, + }; + }); +} diff --git a/shared/frontend/stores/companies.js b/shared/frontend/stores/companies.js new file mode 100644 index 0000000..35f9bbb --- /dev/null +++ b/shared/frontend/stores/companies.js @@ -0,0 +1,196 @@ +/** + * Shared Companies Store Factory + * + * Creates a Pinia store for company selection that can be used by any ROA2WEB application. + * Each app passes its own apiService and auth store instances. + * + * Usage: + * import { createCompaniesStore } from '@shared/frontend/stores/companies'; + * import { apiService } from '../services/api'; + * import { useAuthStore } from './auth'; + * export const useCompanyStore = createCompaniesStore(apiService, useAuthStore); + */ + +import { defineStore } from "pinia"; +import { ref, computed, watch } from "vue"; + +/** + * Factory function to create a companies store + * @param {Object} apiService - Axios instance configured for the app's API + * @param {Function} useAuthStore - Reference to the auth store function + * @returns {Function} Pinia store definition + */ +export function createCompaniesStore(apiService, useAuthStore) { + return defineStore("companies", () => { + // State + const companies = ref([]); + const selectedCompany = ref(null); + const isLoading = ref(false); + const error = ref(null); + + // Initialize from localStorage - per user + const initializeSelectedCompany = () => { + const authStore = useAuthStore(); + const username = authStore.user?.username; + + if (!username) { + console.log("[Companies] No username available for initialization"); + return null; + } + + const key = `selected_company_${username}`; + const saved = localStorage.getItem(key); + if (saved) { + try { + const company = JSON.parse(saved); + console.log(`[Companies] Loaded saved company for ${username}:`, company.name); + return company; + } catch (e) { + console.error("Failed to parse saved company", e); + localStorage.removeItem(key); + } + } + return null; + }; + + // Watch for auth user changes to restore selected company + const authStore = useAuthStore(); + watch( + () => authStore.user, + (newUser) => { + if (newUser && newUser.username && !selectedCompany.value) { + const restoredCompany = initializeSelectedCompany(); + if (restoredCompany) { + selectedCompany.value = restoredCompany; + console.log("[Companies] Restored selected company:", restoredCompany.name); + } + } + }, + { immediate: true } + ); + + // Getters + const companyList = computed(() => companies.value); + const hasCompanies = computed(() => companies.value.length > 0); + const selectedCompanyId = computed(() => selectedCompany.value?.id_firma || null); + + const companyListFormatted = computed(() => { + return companies.value.map((company) => ({ + ...company, + displayName: company.fiscal_code + ? `${company.name} (${company.fiscal_code})` + : company.name, + })); + }); + + // Actions + const loadCompanies = async () => { + isLoading.value = true; + error.value = null; + + try { + console.log("[Companies] Loading companies..."); + const response = await apiService.get("/companies"); + companies.value = response.data.companies || []; + console.log("[Companies] Loaded", companies.value.length, "companies"); + + // Validate saved company is still accessible + if (selectedCompany.value) { + const exists = companies.value.find( + (c) => c.id_firma === selectedCompany.value.id_firma + ); + if (!exists) { + console.warn("[Companies] Saved company not accessible, clearing"); + clearSelectedCompany(); + } + } + + return { success: true }; + } catch (err) { + error.value = err.response?.data?.detail || "Failed to load companies"; + console.error("Failed to load companies:", err); + return { success: false, error: error.value }; + } finally { + isLoading.value = false; + } + }; + + const setSelectedCompany = (company) => { + selectedCompany.value = company; + + const authStore = useAuthStore(); + const username = authStore.user?.username; + + if (!username) { + console.warn("[Companies] Cannot save - no username"); + return; + } + + const key = `selected_company_${username}`; + if (company) { + localStorage.setItem(key, JSON.stringify(company)); + console.log(`[Companies] Saved company for ${username}:`, company.name); + } else { + localStorage.removeItem(key); + } + }; + + const clearSelectedCompany = () => { + selectedCompany.value = null; + + const authStore = useAuthStore(); + const username = authStore.user?.username; + + if (username) { + const key = `selected_company_${username}`; + localStorage.removeItem(key); + } + }; + + const getCompanyById = (id_firma) => { + return companies.value.find( + (company) => company.id_firma === parseInt(id_firma) + ); + }; + + const clearError = () => { + error.value = null; + }; + + const reset = () => { + companies.value = []; + selectedCompany.value = null; + isLoading.value = false; + error.value = null; + + const authStore = useAuthStore(); + const username = authStore.user?.username; + if (username) { + const key = `selected_company_${username}`; + localStorage.removeItem(key); + } + }; + + return { + // State + companies, + selectedCompany, + isLoading, + error, + + // Getters + companyList, + companyListFormatted, + hasCompanies, + selectedCompanyId, + + // Actions + loadCompanies, + setSelectedCompany, + clearSelectedCompany, + getCompanyById, + clearError, + reset, + }; + }); +} diff --git a/shared/frontend/styles/layout/header.css b/shared/frontend/styles/layout/header.css new file mode 100644 index 0000000..9ccb715 --- /dev/null +++ b/shared/frontend/styles/layout/header.css @@ -0,0 +1,167 @@ +/* Shared Header Styles - ROA2WEB */ + +/* Header Container */ +.header-container { + position: sticky; + top: 0; + z-index: var(--z-header, 100); + background: var(--color-bg, #fff); + border-bottom: 1px solid var(--color-border, #e5e7eb); + height: var(--header-height, 60px); + padding: 0 var(--space-lg, 24px); +} + +/* Gradient Header Variant */ +.header-container--gradient { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-bottom: none; +} + +.header-container--gradient .header-brand { + color: white; +} + +.header-container--gradient .hamburger-line { + background-color: white; +} + +/* Header Navigation */ +.header-nav { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 100%; + max-width: 1600px; + margin: 0 auto; +} + +/* Header Left Section */ +.header-left { + display: flex; + align-items: center; + gap: var(--space-md, 16px); +} + +/* Brand/Logo */ +.header-brand { + display: flex; + align-items: center; + gap: var(--space-sm, 8px); + font-size: var(--text-lg, 18px); + font-weight: var(--font-semibold, 600); + color: var(--color-primary, #2563eb); + text-decoration: none; + white-space: nowrap; +} + +.header-brand:hover { + opacity: 0.9; +} + +/* Header Actions (right side) */ +.header-actions { + display: flex; + align-items: center; + gap: var(--space-md, 16px); +} + +/* Hamburger Button */ +.hamburger-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + width: 32px; + height: 32px; + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + z-index: 10; + transition: all 0.3s ease; +} + +.hamburger-btn:hover { + opacity: 0.7; +} + +.hamburger-line { + width: 100%; + height: 3px; + background-color: var(--color-primary, #2563eb); + border-radius: 2px; + transition: all 0.3s ease; + transform-origin: center; +} + +/* Hamburger Animation - X state */ +.hamburger-btn.active .hamburger-line:nth-child(1) { + transform: translateY(9px) rotate(45deg); +} + +.hamburger-btn.active .hamburger-line:nth-child(2) { + opacity: 0; +} + +.hamburger-btn.active .hamburger-line:nth-child(3) { + transform: translateY(-9px) rotate(-45deg); +} + +/* Header User Menu */ +.header-user { + display: flex; + align-items: center; + gap: var(--space-sm, 8px); + padding: var(--space-sm, 8px); + border-radius: var(--radius-md, 6px); + cursor: pointer; + transition: background-color 0.15s ease; + color: var(--color-text, #111827); +} + +.header-user:hover { + background-color: var(--color-bg-secondary, #f9fafb); +} + +/* Gradient header user menu */ +.header-container--gradient .header-user { + color: white; +} + +.header-container--gradient .header-user:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .header-container { + padding: 0 var(--space-md, 12px); + } + + .header-left { + gap: var(--space-sm, 8px); + } + + .header-actions { + gap: var(--space-sm, 8px); + } + + .header-brand { + font-size: var(--text-base, 16px); + } + + /* Hide text-only elements on mobile */ + .desktop-only { + display: none; + } +} + +@media (max-width: 480px) { + .header-brand span { + display: none; + } + + .header-brand i { + font-size: 1.5rem; + } +} diff --git a/shared/frontend/styles/layout/navigation.css b/shared/frontend/styles/layout/navigation.css new file mode 100644 index 0000000..2814d7f --- /dev/null +++ b/shared/frontend/styles/layout/navigation.css @@ -0,0 +1,151 @@ +/* Shared Navigation Styles - ROA2WEB */ + +/* Slide-out Menu */ +.slide-menu { + position: fixed; + top: var(--header-height, 60px); + left: 0; + width: var(--sidebar-width, 280px); + height: calc(100vh - var(--header-height, 60px)); + background: var(--color-bg, #fff); + border-right: 1px solid var(--color-border, #e5e7eb); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + transform: translateX(-100%); + transition: transform 0.3s ease; + z-index: var(--z-modal, 1000); + overflow-y: auto; + /* Flex container for profile section at bottom */ + display: flex; + flex-direction: column; +} + +.slide-menu.open { + transform: translateX(0); +} + +/* Menu Overlay */ +.slide-menu-overlay { + position: fixed; + top: var(--header-height, 60px); + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; + z-index: var(--z-modal-backdrop, 999); +} + +.slide-menu-overlay.open { + opacity: 1; + visibility: visible; +} + +/* Menu Sections */ +.menu-section { + padding: var(--space-lg, 24px); + border-bottom: 1px solid var(--color-border, #e5e7eb); +} + +.menu-section:last-child { + border-bottom: none; +} + +/* Profile section at bottom */ +.menu-section.menu-profile { + margin-top: auto; + border-top: 1px solid var(--color-border, #e5e7eb); + border-bottom: none; +} + +.menu-title { + font-size: var(--text-sm, 14px); + font-weight: var(--font-semibold, 600); + color: var(--color-text-secondary, #6b7280); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-md, 12px); +} + +.menu-list { + list-style: none; + margin: 0; + padding: 0; +} + +.menu-item { + margin-bottom: var(--space-xs, 4px); +} + +.menu-link { + display: flex; + align-items: center; + gap: var(--space-sm, 8px); + padding: var(--space-sm, 8px) var(--space-md, 12px); + color: var(--color-text, #111827); + text-decoration: none; + border-radius: var(--radius-md, 6px); + transition: all 0.15s ease; + font-size: var(--text-sm, 14px); +} + +.menu-link:hover, +.menu-link.active { + background-color: var(--color-bg-secondary, #f9fafb); + color: var(--color-primary, #2563eb); +} + +.menu-icon { + width: 18px; + height: 18px; + flex-shrink: 0; + font-size: 16px; +} + +/* Profile Info */ +.profile-info { + display: flex; + align-items: center; + gap: var(--space-sm, 8px); + padding: var(--space-sm, 8px) var(--space-md, 12px); + margin-bottom: var(--space-sm, 8px); + font-weight: var(--font-medium, 500); + color: var(--color-text, #111827); +} + +.profile-info i { + font-size: 1.25rem; + color: var(--color-primary, #2563eb); +} + +/* Badge for menu items */ +.menu-badge { + margin-left: auto; + background: var(--color-danger, #ef4444); + color: white; + font-size: var(--text-xs, 12px); + font-weight: var(--font-semibold, 600); + padding: 2px 6px; + border-radius: var(--radius-full, 9999px); + min-width: 20px; + text-align: center; +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .slide-menu { + width: 280px; + } + + .menu-section { + padding: var(--space-md, 12px); + } +} + +@media (max-width: 480px) { + .slide-menu { + width: 100vw; + max-width: 320px; + } +} diff --git a/shared/models/__init__.py b/shared/models/__init__.py new file mode 100644 index 0000000..d78bbbc --- /dev/null +++ b/shared/models/__init__.py @@ -0,0 +1,11 @@ +"""Shared Pydantic models for ROA2WEB applications.""" + +from .company import Company, CompanyListResponse +from .calendar import CalendarPeriod, CalendarPeriodsResponse + +__all__ = [ + "Company", + "CompanyListResponse", + "CalendarPeriod", + "CalendarPeriodsResponse", +] diff --git a/shared/models/calendar.py b/shared/models/calendar.py new file mode 100644 index 0000000..5de6969 --- /dev/null +++ b/shared/models/calendar.py @@ -0,0 +1,18 @@ +"""Calendar/accounting period models for ROA2WEB applications.""" + +from typing import List, Optional +from pydantic import BaseModel + + +class CalendarPeriod(BaseModel): + """Model for an accounting period.""" + an: int # Year + luna: int # Month (1-12) + display_name: str # Format: "Decembrie 2025" + + +class CalendarPeriodsResponse(BaseModel): + """Response model for calendar periods list.""" + periods: List[CalendarPeriod] + current_period: Optional[CalendarPeriod] = None + total_count: int diff --git a/shared/models/company.py b/shared/models/company.py new file mode 100644 index 0000000..ffdfaae --- /dev/null +++ b/shared/models/company.py @@ -0,0 +1,19 @@ +"""Company models for ROA2WEB applications.""" + +from typing import List, Optional +from pydantic import BaseModel + + +class Company(BaseModel): + """Model for a company/firma.""" + id_firma: int + name: str + schema_name: str + fiscal_code: Optional[str] = None + is_active: bool = True + + +class CompanyListResponse(BaseModel): + """Response model for list of companies.""" + companies: List[Company] + total_count: int diff --git a/shared/routes/__init__.py b/shared/routes/__init__.py new file mode 100644 index 0000000..9e8effb --- /dev/null +++ b/shared/routes/__init__.py @@ -0,0 +1,21 @@ +""" +Shared Routes for ROA2WEB Applications + +This module provides factory functions for creating common API routers +that can be mounted in both reports-app and data-entry-app. + +Usage: + from shared.routes import create_companies_router, create_calendar_router + + # In main.py + companies_router = create_companies_router(oracle_pool) + app.include_router(companies_router, prefix="/api/companies") +""" + +from .companies import create_companies_router +from .calendar import create_calendar_router + +__all__ = [ + "create_companies_router", + "create_calendar_router", +] diff --git a/shared/routes/calendar.py b/shared/routes/calendar.py new file mode 100644 index 0000000..f2b144c --- /dev/null +++ b/shared/routes/calendar.py @@ -0,0 +1,136 @@ +""" +Shared Calendar Router Factory for ROA2WEB Applications + +Creates a FastAPI router for /api/calendar endpoints that can be used +by both reports-app and data-entry-app. + +Usage: + from shared.routes.calendar import create_calendar_router + + calendar_router = create_calendar_router(oracle_pool, cache_decorator=cached) + app.include_router(calendar_router, prefix="/api/calendar") +""" + +import logging +from typing import Optional, Callable, List + +from fastapi import APIRouter, Depends, HTTPException, Query + +from auth.dependencies import get_current_user +from auth.models import CurrentUser +from models.calendar import CalendarPeriod, CalendarPeriodsResponse + +logger = logging.getLogger(__name__) + +# Romanian month names +MONTH_NAMES_RO = [ + "Ianuarie", "Februarie", "Martie", "Aprilie", "Mai", "Iunie", + "Iulie", "August", "Septembrie", "Octombrie", "Noiembrie", "Decembrie" +] + + +def create_calendar_router( + oracle_pool, + cache_decorator: Optional[Callable] = None, + tags: Optional[List[str]] = None +) -> APIRouter: + """ + Factory function to create a calendar router. + + Args: + oracle_pool: The Oracle connection pool instance + cache_decorator: Optional caching decorator (e.g., @cached) + tags: OpenAPI tags for the router + + Returns: + Configured FastAPI router for calendar endpoints + """ + router = APIRouter( + redirect_slashes=False, + tags=tags or ["calendar"] + ) + + # Helper to get schema for company + async def _get_schema_for_company(company_id: int) -> Optional[str]: + """Get Oracle schema for company ID.""" + async with oracle_pool.get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute(""" + SELECT SCHEMA FROM CONTAFIN_ORACLE.V_NOM_FIRME + WHERE ID_FIRMA = :company_id + """, {'company_id': company_id}) + result = cursor.fetchone() + return result[0] if result else None + + # Apply cache to schema lookup if decorator provided + if cache_decorator: + _get_schema_for_company = cache_decorator( + cache_type='schema', + key_params=['company_id'] + )(_get_schema_for_company) + + # Helper to get periods - can be cached + async def _get_available_periods(company_id: int) -> CalendarPeriodsResponse: + """Get available accounting periods for a company.""" + schema = await _get_schema_for_company(company_id) + if not schema: + logger.warning(f"Schema not found for company {company_id}") + return CalendarPeriodsResponse(periods=[], current_period=None, total_count=0) + + try: + async with oracle_pool.get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute(f""" + SELECT ANUL, LUNA + FROM {schema}.CALENDAR + ORDER BY ANUL DESC, LUNA DESC + """) + rows = cursor.fetchall() + + periods = [] + for row in rows: + an, luna = row[0], row[1] + month_name = MONTH_NAMES_RO[luna - 1] + periods.append(CalendarPeriod( + an=an, + luna=luna, + display_name=f"{month_name} {an}" + )) + + current_period = periods[0] if periods else None + + logger.info(f"Loaded {len(periods)} periods for company {company_id}") + + return CalendarPeriodsResponse( + periods=periods, + current_period=current_period, + total_count=len(periods) + ) + + except Exception as e: + logger.error(f"Error fetching periods for company {company_id}: {e}") + return CalendarPeriodsResponse(periods=[], current_period=None, total_count=0) + + # Apply cache decorator if provided + if cache_decorator: + _get_available_periods = cache_decorator( + cache_type='calendar_periods', + key_params=['company_id'] + )(_get_available_periods) + + @router.get("/periods", response_model=CalendarPeriodsResponse) + async def get_calendar_periods( + company: int = Query(..., description="Company ID"), + current_user: CurrentUser = Depends(get_current_user) + ) -> CalendarPeriodsResponse: + """ + Get available accounting periods for a company. + Returns periods ordered by year DESC, month DESC with Romanian month names. + """ + # Validate company access + if str(company) not in current_user.companies: + raise HTTPException(403, f"Nu aveศ›i acces la firma {company}") + + return await _get_available_periods(company) + + return router diff --git a/shared/routes/companies.py b/shared/routes/companies.py new file mode 100644 index 0000000..4b0abcb --- /dev/null +++ b/shared/routes/companies.py @@ -0,0 +1,175 @@ +""" +Shared Companies Router Factory for ROA2WEB Applications + +Creates a FastAPI router for /api/companies endpoints that can be used +by both reports-app and data-entry-app. + +Usage: + from shared.routes.companies import create_companies_router + + companies_router = create_companies_router(oracle_pool, cache_decorator=cached) + app.include_router(companies_router, prefix="/api/companies") +""" + +import logging +from typing import Optional, Callable, List + +from fastapi import APIRouter, Depends, HTTPException, Request + +from auth.dependencies import get_current_user +from auth.models import CurrentUser +from models.company import Company, CompanyListResponse + +logger = logging.getLogger(__name__) + + +def create_companies_router( + oracle_pool, + cache_decorator: Optional[Callable] = None, + tags: Optional[List[str]] = None +) -> APIRouter: + """ + Factory function to create a companies router. + + Args: + oracle_pool: The Oracle connection pool instance + cache_decorator: Optional caching decorator (e.g., @cached) + tags: OpenAPI tags for the router + + Returns: + Configured FastAPI router for company endpoints + """ + router = APIRouter( + redirect_slashes=False, + tags=tags or ["companies"] + ) + + # Helper function to get companies - can be cached + async def _get_user_companies_data(username: str) -> List[Company]: + """ + Get list of companies for a user from Oracle. + """ + companies = [] + + async with oracle_pool.get_connection() as connection: + with connection.cursor() as cursor: + try: + # Get user ID + cursor.execute(""" + SELECT ID_UTIL, UTILIZATOR + FROM UTILIZATORI + WHERE UPPER(UTILIZATOR) = :username + """, {'username': username.upper()}) + + user_row = cursor.fetchone() + if not user_row: + logger.warning(f"User {username} not found in UTILIZATORI") + return [] + + user_id = user_row[0] + + # Get companies for user (program 2 = data entry/reports) + cursor.execute(""" + SELECT A.ID_FIRMA, A.FIRMA, A.SCHEMA, A.COD_FISCAL + FROM V_NOM_FIRME A + WHERE A.ID_FIRMA IN ( + SELECT ID_FIRMA + FROM VDEF_UTIL_FIRME + WHERE ID_PROGRAM = 2 AND ID_UTIL = :user_id + ) + ORDER BY A.FIRMA + """, {'user_id': user_id}) + + for row in cursor.fetchall(): + companies.append(Company( + id_firma=row[0], + name=row[1], + schema_name=row[2], + fiscal_code=row[3], + is_active=True + )) + + logger.info(f"Found {len(companies)} companies for user {username}") + + except Exception as e: + logger.error(f"Error fetching companies: {e}") + + return companies + + # Apply cache decorator if provided + if cache_decorator: + _get_user_companies_data = cache_decorator( + cache_type='companies', + key_params=['username'] + )(_get_user_companies_data) + + @router.get("", response_model=CompanyListResponse) + @router.get("/", response_model=CompanyListResponse) + async def get_user_companies( + request: Request, + current_user: CurrentUser = Depends(get_current_user) + ): + """Get list of companies the user has access to.""" + try: + companies = await _get_user_companies_data(current_user.username) + + return CompanyListResponse( + companies=companies, + total_count=len(companies) + ) + except Exception as e: + logger.error(f"Error in get_user_companies: {e}") + raise HTTPException(500, f"Eroare la obศ›inerea listei de firme: {str(e)}") + + @router.get("/{company_id}", response_model=Company) + async def get_company_details( + company_id: str, + current_user: CurrentUser = Depends(get_current_user) + ): + """Get details of a specific company.""" + # Validate access + if company_id not in current_user.companies: + raise HTTPException(403, f"Nu aveศ›i acces la firma {company_id}") + + try: + async with oracle_pool.get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute(""" + SELECT ID_FIRMA, FIRMA, SCHEMA, COD_FISCAL + FROM V_NOM_FIRME + WHERE ID_FIRMA = :company_id + """, {'company_id': int(company_id)}) + row = cursor.fetchone() + + if not row: + raise HTTPException(404, f"Firma {company_id} nu a fost gฤƒsitฤƒ") + + return Company( + id_firma=row[0], + name=row[1], + schema_name=row[2], + fiscal_code=row[3] or "", + is_active=True + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(500, f"Eroare la obศ›inerea detaliilor firmei: {str(e)}") + + @router.get("/{company_id}/validate") + async def validate_company_access( + company_id: str, + current_user: CurrentUser = Depends(get_current_user) + ): + """Validate if user has access to a company.""" + has_access = company_id in current_user.companies + + return { + "company_id": company_id, + "has_access": has_access, + "user": current_user.username, + "message": "Acces validat" if has_access else "Acces refuzat" + } + + return router diff --git a/start-data-entry-dev.sh b/start-data-entry-dev.sh new file mode 100644 index 0000000..8d41dc3 --- /dev/null +++ b/start-data-entry-dev.sh @@ -0,0 +1,224 @@ +#!/bin/bash + +# Data Entry App - PRODUCTION Starter Script +# Oracle Server: 10.0.20.36 (via ssh_tunnel.sh) +# Database: receipts_prod.db +# Schema test: ROMFAST (company_id=114) + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +NC='\033[0m' + +print_message() { echo -e "${BLUE}[DATA-ENTRY-PROD]${NC} $1"; } +print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } +print_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +check_port() { + local port=$1 + lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1 +} + +cleanup() { + print_message "Stopping services..." + [[ -n $BACKEND_PID ]] && kill $BACKEND_PID 2>/dev/null || true + [[ -n $FRONTEND_PID ]] && kill $FRONTEND_PID 2>/dev/null || true + ./ssh_tunnel.sh stop 2>/dev/null || true + print_success "All services stopped." + exit 0 +} + +stop_services() { + print_message "Stopping all Data Entry PRODUCTION services..." + + if check_port 8003; then + lsof -ti:8003 | xargs kill -TERM 2>/dev/null || true + sleep 2 + lsof -ti:8003 | xargs kill -KILL 2>/dev/null || true + print_success "Backend stopped" + fi + + if check_port 3010; then + lsof -ti:3010 | xargs kill -TERM 2>/dev/null || true + sleep 2 + lsof -ti:3010 | xargs kill -KILL 2>/dev/null || true + print_success "Frontend stopped" + fi + + ./ssh_tunnel.sh stop 2>/dev/null || true + + pkill -f "uvicorn.*data-entry" 2>/dev/null || true + pkill -f "vite.*3010" 2>/dev/null || true + + print_success "All Data Entry PRODUCTION services stopped!" + exit 0 +} + +show_status() { + echo -e "${MAGENTA}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" + echo -e "${MAGENTA} Data Entry App - PRODUCTION Status${NC}" + echo -e "${MAGENTA}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" + echo + + if pgrep -f "ssh.*1526.*10.0.20.36" > /dev/null 2>&1 || ./ssh_tunnel.sh status 2>&1 | grep -q "10.0.20.36"; then + echo -e " SSH Tunnel: ${GREEN}โœ“ PRODUCTION (10.0.20.36)${NC}" + elif pgrep -f "ssh.*1526" > /dev/null 2>&1; then + echo -e " SSH Tunnel: ${YELLOW}โš  Running (check which server)${NC}" + else + echo -e " SSH Tunnel: ${RED}โœ— Stopped${NC}" + fi + + if check_port 8003; then + echo -e " Backend: ${GREEN}โœ“ Running${NC} (http://localhost:8003)" + else + echo -e " Backend: ${RED}โœ— Stopped${NC}" + fi + + if check_port 3010; then + echo -e " Frontend: ${GREEN}โœ“ Running${NC} (http://localhost:3010)" + else + echo -e " Frontend: ${RED}โœ— Stopped${NC}" + fi + + echo + echo -e " Database: ${BLUE}receipts_prod.db${NC}" + echo -e " Oracle: ${BLUE}10.0.20.36 (PRODUCTION)${NC}" + echo -e " Test Schema: ${BLUE}ROMFAST (company_id=114)${NC}" + echo +} + +show_usage() { + echo -e "${MAGENTA}Data Entry App - PRODUCTION Starter${NC}" + echo + echo "Usage:" + echo " ./start-data-entry-dev.sh Start all services" + echo " ./start-data-entry-dev.sh stop Stop all services" + echo " ./start-data-entry-dev.sh status Show status" + echo + echo "Environment:" + echo " Oracle Server: 10.0.20.36 (PRODUCTION)" + echo " SSH Tunnel: ./ssh_tunnel.sh" + echo " Config: .env.prod" + echo " Database: receipts_prod.db" + echo +} + +# Parse arguments +case $1 in + stop) stop_services ;; + status) show_status; exit 0 ;; + help|--help|-h) show_usage; exit 0 ;; + "") ;; # Continue to start + *) print_error "Unknown: $1"; show_usage; exit 1 ;; +esac + +trap cleanup SIGINT SIGTERM + +echo -e "${MAGENTA}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo -e "${MAGENTA} Data Entry App - PRODUCTION Environment${NC}" +echo -e "${MAGENTA} Oracle: 10.0.20.36 | DB: receipts_prod.db${NC}" +echo -e "${MAGENTA}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo + +# Step 1: Stop any TEST tunnel and start PRODUCTION tunnel +print_message "1. Setting up SSH Tunnel (PRODUCTION)..." +./ssh-tunnel-test.sh stop 2>/dev/null || true +sleep 1 + +if ./ssh_tunnel.sh start; then + print_success "SSH Tunnel to PRODUCTION (10.0.20.36) started" +else + print_error "Failed to start SSH tunnel" + exit 1 +fi +sleep 2 + +# Step 2: Copy PRODUCTION .env +print_message "2. Loading PRODUCTION environment..." +cp data-entry-app/backend/.env.prod data-entry-app/backend/.env +print_success "Loaded .env.prod (receipts_prod.db)" + +# Step 3: Start Frontend +print_message "3. Starting Frontend (Vue.js)..." + +cd data-entry-app/frontend/ + +if [ ! -d "node_modules" ] || [ -f "node_modules/.bin/vite.cmd" ]; then + print_message "Installing frontend dependencies..." + rm -rf node_modules package-lock.json 2>/dev/null + npm install +fi + +npm run dev -- --port 3010 > /tmp/data_entry_frontend.log 2>&1 & +FRONTEND_PID=$! + +for i in {1..15}; do + if check_port 3010; then + print_success "Frontend started on http://localhost:3010" + break + fi + [ $i -eq 15 ] && { print_error "Frontend failed"; cat /tmp/data_entry_frontend.log; cleanup; } + sleep 1 +done + +# Step 4: Start Backend +print_message "4. Starting Backend (FastAPI)..." + +cd ../backend/ + +if [ ! -d "venv" ]; then + print_message "Creating virtual environment..." + python3 -m venv venv +fi + +source venv/bin/activate + +if ! python -c "import fastapi, uvicorn, sqlmodel" 2>/dev/null; then + print_message "Installing backend dependencies..." + pip install --upgrade pip > /dev/null 2>&1 + pip install -r requirements.txt +fi + +mkdir -p data/uploads + +print_message "Running migrations..." +alembic upgrade head 2>/dev/null || print_warning "Migrations may already be applied" + +uvicorn app.main:app --host 0.0.0.0 --port 8003 --reload > /tmp/data_entry_backend.log 2>&1 & +BACKEND_PID=$! + +for i in {1..20}; do + if check_port 8003; then + print_success "Backend started on http://localhost:8003" + break + fi + [ $i -eq 20 ] && { print_error "Backend failed"; cat /tmp/data_entry_backend.log; cleanup; } + sleep 1 +done + +# Summary +echo +echo -e "${GREEN}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo -e "${GREEN} Data Entry PRODUCTION Environment Ready!${NC}" +echo -e "${GREEN}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo +echo -e "${BLUE}Services:${NC}" +echo " โ€ข SSH Tunnel: 10.0.20.36 (PRODUCTION)" +echo " โ€ข Backend: http://localhost:8003" +echo " โ€ข Frontend: http://localhost:3010" +echo " โ€ข API Docs: http://localhost:8003/docs" +echo +echo -e "${BLUE}Database:${NC}" +echo " โ€ข SQLite: data/receipts_prod.db" +echo " โ€ข Test Company: ROMFAST (company_id=114)" +echo +echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}" +echo + +wait diff --git a/start-data-entry-test.sh b/start-data-entry-test.sh new file mode 100644 index 0000000..a67d7f2 --- /dev/null +++ b/start-data-entry-test.sh @@ -0,0 +1,224 @@ +#!/bin/bash + +# Data Entry App - TEST Starter Script +# Oracle Server: 10.0.20.121 (via ssh-tunnel-test.sh) +# Database: receipts_test.db +# Schema test: MARIUSM_AUTO (company_id=110) + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +print_message() { echo -e "${CYAN}[DATA-ENTRY-TEST]${NC} $1"; } +print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } +print_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +check_port() { + local port=$1 + lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1 +} + +cleanup() { + print_message "Stopping services..." + [[ -n $BACKEND_PID ]] && kill $BACKEND_PID 2>/dev/null || true + [[ -n $FRONTEND_PID ]] && kill $FRONTEND_PID 2>/dev/null || true + ./ssh-tunnel-test.sh stop 2>/dev/null || true + print_success "All services stopped." + exit 0 +} + +stop_services() { + print_message "Stopping all Data Entry TEST services..." + + if check_port 8003; then + lsof -ti:8003 | xargs kill -TERM 2>/dev/null || true + sleep 2 + lsof -ti:8003 | xargs kill -KILL 2>/dev/null || true + print_success "Backend stopped" + fi + + if check_port 3010; then + lsof -ti:3010 | xargs kill -TERM 2>/dev/null || true + sleep 2 + lsof -ti:3010 | xargs kill -KILL 2>/dev/null || true + print_success "Frontend stopped" + fi + + ./ssh-tunnel-test.sh stop 2>/dev/null || true + + pkill -f "uvicorn.*data-entry" 2>/dev/null || true + pkill -f "vite.*3010" 2>/dev/null || true + + print_success "All Data Entry TEST services stopped!" + exit 0 +} + +show_status() { + echo -e "${CYAN}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" + echo -e "${CYAN} Data Entry App - TEST Status${NC}" + echo -e "${CYAN}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" + echo + + if pgrep -f "ssh.*10.0.20.121" > /dev/null 2>&1 || ./ssh-tunnel-test.sh status 2>&1 | grep -q "10.0.20.121"; then + echo -e " SSH Tunnel: ${GREEN}โœ“ TEST (10.0.20.121)${NC}" + elif pgrep -f "ssh.*1526" > /dev/null 2>&1; then + echo -e " SSH Tunnel: ${YELLOW}โš  Running (check which server)${NC}" + else + echo -e " SSH Tunnel: ${RED}โœ— Stopped${NC}" + fi + + if check_port 8003; then + echo -e " Backend: ${GREEN}โœ“ Running${NC} (http://localhost:8003)" + else + echo -e " Backend: ${RED}โœ— Stopped${NC}" + fi + + if check_port 3010; then + echo -e " Frontend: ${GREEN}โœ“ Running${NC} (http://localhost:3010)" + else + echo -e " Frontend: ${RED}โœ— Stopped${NC}" + fi + + echo + echo -e " Database: ${CYAN}receipts_test.db${NC}" + echo -e " Oracle: ${CYAN}10.0.20.121 (TEST)${NC}" + echo -e " Test Schema: ${CYAN}MARIUSM_AUTO (company_id=110)${NC}" + echo +} + +show_usage() { + echo -e "${CYAN}Data Entry App - TEST Starter${NC}" + echo + echo "Usage:" + echo " ./start-data-entry-test.sh Start all services" + echo " ./start-data-entry-test.sh stop Stop all services" + echo " ./start-data-entry-test.sh status Show status" + echo + echo "Environment:" + echo " Oracle Server: 10.0.20.121 (TEST)" + echo " SSH Tunnel: ./ssh-tunnel-test.sh" + echo " Config: .env.test" + echo " Database: receipts_test.db" + echo +} + +# Parse arguments +case $1 in + stop) stop_services ;; + status) show_status; exit 0 ;; + help|--help|-h) show_usage; exit 0 ;; + "") ;; # Continue to start + *) print_error "Unknown: $1"; show_usage; exit 1 ;; +esac + +trap cleanup SIGINT SIGTERM + +echo -e "${CYAN}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo -e "${CYAN} Data Entry App - TEST Environment${NC}" +echo -e "${CYAN} Oracle: 10.0.20.121 | DB: receipts_test.db${NC}" +echo -e "${CYAN}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo + +# Step 1: Stop any PRODUCTION tunnel and start TEST tunnel +print_message "1. Setting up SSH Tunnel (TEST)..." +./ssh_tunnel.sh stop 2>/dev/null || true +sleep 1 + +if ./ssh-tunnel-test.sh start; then + print_success "SSH Tunnel to TEST (10.0.20.121) started" +else + print_error "Failed to start SSH tunnel" + exit 1 +fi +sleep 2 + +# Step 2: Copy TEST .env +print_message "2. Loading TEST environment..." +cp data-entry-app/backend/.env.test data-entry-app/backend/.env +print_success "Loaded .env.test (receipts_test.db)" + +# Step 3: Start Frontend +print_message "3. Starting Frontend (Vue.js)..." + +cd data-entry-app/frontend/ + +if [ ! -d "node_modules" ] || [ -f "node_modules/.bin/vite.cmd" ]; then + print_message "Installing frontend dependencies..." + rm -rf node_modules package-lock.json 2>/dev/null + npm install +fi + +npm run dev -- --port 3010 > /tmp/data_entry_frontend.log 2>&1 & +FRONTEND_PID=$! + +for i in {1..15}; do + if check_port 3010; then + print_success "Frontend started on http://localhost:3010" + break + fi + [ $i -eq 15 ] && { print_error "Frontend failed"; cat /tmp/data_entry_frontend.log; cleanup; } + sleep 1 +done + +# Step 4: Start Backend +print_message "4. Starting Backend (FastAPI)..." + +cd ../backend/ + +if [ ! -d "venv" ]; then + print_message "Creating virtual environment..." + python3 -m venv venv +fi + +source venv/bin/activate + +if ! python -c "import fastapi, uvicorn, sqlmodel" 2>/dev/null; then + print_message "Installing backend dependencies..." + pip install --upgrade pip > /dev/null 2>&1 + pip install -r requirements.txt +fi + +mkdir -p data/uploads + +print_message "Running migrations..." +alembic upgrade head 2>/dev/null || print_warning "Migrations may already be applied" + +uvicorn app.main:app --host 0.0.0.0 --port 8003 --reload > /tmp/data_entry_backend.log 2>&1 & +BACKEND_PID=$! + +for i in {1..20}; do + if check_port 8003; then + print_success "Backend started on http://localhost:8003" + break + fi + [ $i -eq 20 ] && { print_error "Backend failed"; cat /tmp/data_entry_backend.log; cleanup; } + sleep 1 +done + +# Summary +echo +echo -e "${GREEN}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo -e "${GREEN} Data Entry TEST Environment Ready!${NC}" +echo -e "${GREEN}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo +echo -e "${CYAN}Services:${NC}" +echo " โ€ข SSH Tunnel: 10.0.20.121 (TEST)" +echo " โ€ข Backend: http://localhost:8003" +echo " โ€ข Frontend: http://localhost:3010" +echo " โ€ข API Docs: http://localhost:8003/docs" +echo +echo -e "${CYAN}Database:${NC}" +echo " โ€ข SQLite: data/receipts_test.db" +echo " โ€ข Test Company: MARIUSM_AUTO (company_id=110)" +echo +echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}" +echo + +wait diff --git a/start-data-entry.sh b/start-data-entry.sh deleted file mode 100644 index d070fc9..0000000 --- a/start-data-entry.sh +++ /dev/null @@ -1,472 +0,0 @@ -#!/bin/bash - -# Data Entry App Starter Script -# Starts backend and frontend services for the data entry application - -set -e # Exit on any error - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Function to print colored messages -print_message() { - echo -e "${BLUE}[DATA-ENTRY]${NC} $1" -} - -print_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -print_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -print_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Function to check if port is in use -check_port() { - local port=$1 - if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1; then - return 0 - else - return 1 - fi -} - -# Function to cleanup processes on exit -cleanup() { - print_message "Stopping services..." - - # Kill background processes - if [[ -n $BACKEND_PID ]]; then - kill $BACKEND_PID 2>/dev/null || true - fi - - if [[ -n $FRONTEND_PID ]]; then - kill $FRONTEND_PID 2>/dev/null || true - fi - - print_success "All services stopped." - exit 0 -} - -# Function to stop all services -stop_services() { - print_message "Stopping all Data Entry services..." - - # Stop backend on port 8003 - print_message "Checking for backend processes on port 8003..." - if check_port 8003; then - BACKEND_PIDS=$(lsof -ti:8003) - if [[ -n $BACKEND_PIDS ]]; then - echo $BACKEND_PIDS | xargs kill -TERM 2>/dev/null || true - sleep 2 - echo $BACKEND_PIDS | xargs kill -KILL 2>/dev/null || true - print_success "Backend processes stopped" - fi - else - print_message "No backend processes found on port 8003" - fi - - # Stop frontend on port 3010 - print_message "Checking for frontend processes on port 3010..." - if check_port 3010; then - FRONTEND_PIDS=$(lsof -ti:3010) - if [[ -n $FRONTEND_PIDS ]]; then - echo $FRONTEND_PIDS | xargs kill -TERM 2>/dev/null || true - sleep 2 - echo $FRONTEND_PIDS | xargs kill -KILL 2>/dev/null || true - print_success "Frontend processes stopped" - fi - else - print_message "No frontend processes found on port 3010" - fi - - # Kill any remaining processes related to data-entry - pkill -f "uvicorn.*data-entry" 2>/dev/null || true - pkill -f "node.*data-entry" 2>/dev/null || true - pkill -f "vite.*3010" 2>/dev/null || true - - print_success "โœ… All Data Entry services have been stopped!" - exit 0 -} - -# Function to stop individual service -stop_service() { - local service=$1 - - case $service in - backend) - print_message "Stopping backend..." - if check_port 8003; then - BACKEND_PIDS=$(lsof -ti:8003) - if [[ -n $BACKEND_PIDS ]]; then - echo $BACKEND_PIDS | xargs kill -TERM 2>/dev/null || true - sleep 2 - echo $BACKEND_PIDS | xargs kill -KILL 2>/dev/null || true - print_success "Backend stopped" - else - print_message "Backend not running" - fi - else - print_message "Backend not running on port 8003" - fi - ;; - frontend) - print_message "Stopping frontend..." - if check_port 3010; then - FRONTEND_PIDS=$(lsof -ti:3010) - if [[ -n $FRONTEND_PIDS ]]; then - echo $FRONTEND_PIDS | xargs kill -TERM 2>/dev/null || true - sleep 2 - echo $FRONTEND_PIDS | xargs kill -KILL 2>/dev/null || true - print_success "Frontend stopped" - fi - else - print_message "Frontend not running on port 3010" - fi - ;; - *) - print_error "Unknown service: $service" - print_message "Valid services: backend, frontend" - exit 1 - ;; - esac -} - -# Function to start individual service -start_service() { - local service=$1 - - case $service in - backend) - print_message "Starting backend..." - if check_port 8003; then - print_warning "Port 8003 already in use. Backend might be running." - return 1 - fi - - cd data-entry-app/backend/ - - # Check if virtual environment exists - if [ ! -d "venv" ]; then - print_message "Creating Python virtual environment..." - python3 -m venv venv - fi - - # Activate virtual environment - source venv/bin/activate - - # Check if dependencies are installed - if ! python -c "import fastapi, uvicorn, sqlmodel" 2>/dev/null; then - print_message "Installing backend dependencies..." - pip install --upgrade pip > /dev/null 2>&1 - pip install -r requirements.txt - fi - - # Run Alembic migrations - print_message "Running database migrations..." - alembic upgrade head 2>/dev/null || print_warning "Migration may have already been applied" - - print_message "Starting uvicorn server..." - nohup uvicorn app.main:app --host 0.0.0.0 --port 8003 --reload > /tmp/data_entry_backend.log 2>&1 & - - sleep 2 - for i in {1..10}; do - if check_port 8003; then - print_success "Backend started on http://localhost:8003" - cd - > /dev/null - return 0 - fi - sleep 1 - done - print_error "Backend failed to start" - cat /tmp/data_entry_backend.log - cd - > /dev/null - exit 1 - ;; - frontend) - print_message "Starting frontend..." - if check_port 3010; then - print_warning "Port 3010 already in use. Frontend might be running." - return 1 - fi - - cd data-entry-app/frontend/ - - # Check node_modules - if [ ! -d "node_modules" ] || [ ! -f "node_modules/.bin/vite" ]; then - print_message "Installing frontend dependencies..." - npm install - fi - - print_message "Starting Vite development server..." - nohup npm run dev -- --port 3010 > /tmp/data_entry_frontend.log 2>&1 & - - sleep 3 - for i in {1..10}; do - if check_port 3010; then - print_success "Frontend started on http://localhost:3010" - cd - > /dev/null - return 0 - fi - sleep 1 - done - - print_error "Frontend failed to start" - cat /tmp/data_entry_frontend.log - cd - > /dev/null - exit 1 - ;; - *) - print_error "Unknown service: $service" - print_message "Valid services: backend, frontend" - exit 1 - ;; - esac -} - -# Function to restart individual service -restart_service() { - local service=$1 - print_message "Restarting $service..." - stop_service $service - sleep 2 - start_service $service - print_success "$service restarted successfully" -} - -# Function to show service status -show_status() { - echo -e "${BLUE}Data Entry Services Status${NC}" - echo - - # Check backend - if check_port 8003; then - echo -e " Backend: ${GREEN}โœ“ Running${NC} (http://localhost:8003)" - else - echo -e " Backend: ${RED}โœ— Stopped${NC}" - fi - - # Check frontend - if check_port 3010; then - echo -e " Frontend: ${GREEN}โœ“ Running${NC} (http://localhost:3010)" - else - echo -e " Frontend: ${RED}โœ— Stopped${NC}" - fi - - echo -} - -# Function to show usage -show_usage() { - echo -e "${BLUE}Data Entry App Starter Script${NC}" - echo - echo "Usage:" - echo " ./start-data-entry.sh Start all services" - echo " ./start-data-entry.sh start Start all services" - echo " ./start-data-entry.sh stop Stop all services" - echo " ./start-data-entry.sh status Show services status" - echo - echo " ./start-data-entry.sh restart Restart specific service" - echo " ./start-data-entry.sh start Start specific service" - echo " ./start-data-entry.sh stop Stop specific service" - echo - echo "Services:" - echo " backend - FastAPI (port 8003)" - echo " frontend - Vue.js/Vite (port 3010)" - echo - echo "Examples:" - echo " ./start-data-entry.sh restart backend Restart only backend" - echo " ./start-data-entry.sh stop frontend Stop only frontend" - echo -} - -# Check command line arguments -case $1 in - stop) - if [[ -n $2 ]]; then - stop_service $2 - exit 0 - else - stop_services - fi - ;; - start) - if [[ -n $2 ]]; then - start_service $2 - exit 0 - else - true # Continue with normal start process - fi - ;; - restart) - if [[ -z $2 ]]; then - print_error "Please specify which service to restart" - echo - show_usage - exit 1 - fi - restart_service $2 - exit 0 - ;; - status) - show_status - exit 0 - ;; - help|--help|-h) - show_usage - exit 0 - ;; - "") - # No parameter - start all services - true - ;; - *) - print_error "Unknown parameter: $1" - echo - show_usage - exit 1 - ;; -esac - -# Set up signal handlers -trap cleanup SIGINT SIGTERM - -print_message "Starting Data Entry Development Environment..." -echo - -# Step 1: Start Frontend FIRST (for fast UI availability) -print_message "1. Starting Frontend (Vue.js)..." - -cd data-entry-app/frontend/ - -# Check if node_modules exists -if [ ! -d "node_modules" ]; then - print_message "Installing frontend dependencies..." - npm install -fi - -# Check for WSL compatibility -if [ -f "node_modules/.bin/vite.cmd" ] && [ ! -f "node_modules/.bin/vite" ]; then - print_warning "Windows node_modules detected, reinstalling for WSL..." - rm -rf node_modules package-lock.json - npm install -fi - -# Start frontend in background -print_message "Starting Vite development server..." -npm run dev -- --port 3010 > /tmp/data_entry_frontend.log 2>&1 & -FRONTEND_PID=$! - -# Wait for frontend to start -sleep 3 -for i in {1..10}; do - if check_port 3010; then - print_success "Frontend started on http://localhost:3010" - break - fi - if [ $i -eq 10 ]; then - print_error "Frontend failed to start after 10 attempts" - print_message "Check log at /tmp/data_entry_frontend.log" - cat /tmp/data_entry_frontend.log - cleanup - fi - sleep 1 -done - -# Step 2: Start Backend (with OCR loading in background) -print_message "2. Starting Backend (FastAPI + OCR)..." - -# Check if backend port is already in use -if check_port 8003; then - print_warning "Port 8003 is already in use. Backend might already be running." - read -p "Continue anyway? (y/n): " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - cleanup - fi -fi - -cd ../backend/ - -# Check if .env file exists -if [ ! -f ".env" ]; then - if [ -f ".env.example" ]; then - print_message "Creating .env from .env.example..." - cp .env.example .env - else - print_error ".env file not found!" - exit 1 - fi -fi - -# Check if virtual environment exists -if [ ! -d "venv" ]; then - print_message "Creating Python virtual environment..." - python3 -m venv venv -fi - -# Activate virtual environment -source venv/bin/activate - -# Check if dependencies are installed -if ! python -c "import fastapi, uvicorn, sqlmodel" 2>/dev/null; then - print_message "Installing backend dependencies..." - pip install --upgrade pip > /dev/null 2>&1 - pip install -r requirements.txt -fi - -# Ensure data directories exist -mkdir -p data/uploads - -# Run Alembic migrations -print_message "Running database migrations..." -alembic upgrade head 2>/dev/null || print_warning "Migration may have already been applied or first run" - -# Start backend in background (OCR loads asynchronously) -print_message "Starting uvicorn server (OCR loads in background)..." -uvicorn app.main:app --host 0.0.0.0 --port 8003 --reload > /tmp/data_entry_backend.log 2>&1 & -BACKEND_PID=$! - -# Wait for backend to start (uvicorn --reload takes longer to bind) -sleep 3 -for i in {1..20}; do - if check_port 8003; then - print_success "Backend started on http://localhost:8003" - print_message "Note: OCR engine loading in background (first OCR request may be slow)" - break - fi - if [ $i -eq 20 ]; then - print_error "Backend failed to start after 20 attempts" - cat /tmp/data_entry_backend.log - cleanup - fi - sleep 1 -done - -# Summary -echo -print_success "๐Ÿš€ Data Entry Development Environment is now running!" -echo -echo -e "${BLUE}Services:${NC}" -echo " โ€ข Backend: http://localhost:8003" -echo " โ€ข Frontend: http://localhost:3010" -echo " โ€ข API Docs: http://localhost:8003/docs" -echo -echo -e "${BLUE}Quick Links:${NC}" -echo " โ€ข Lista Bonuri: http://localhost:3010/" -echo " โ€ข Bon Nou: http://localhost:3010/create" -echo " โ€ข Aprobare: http://localhost:3010/approval" -echo -echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}" -echo - -# Keep script running and wait for user interrupt -wait