diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 00000000..213ce0c3 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,171 @@ +# AgroPlatform Roadmap + +> Living document. Agent updates this after each PR merges. +> For context on individual TZ points, see `docs/TZ.md`. +> For locked architectural decisions, see "Decisions locked" section below. + +--- + +## Completed + +- [x] **PR #602** — honest dashboard, removed demo margin fake, Season = all-time +- [x] **PR #603** — removed non-functional Plan/Fact card from /expenses *(TZ 7)* +- [x] **PR #604** — root route `/` → landing page, forced dark-only theme *(TZ 1, 8.1)* +- [x] **PR #605** — dashboard period URL state (`?period=`), resolved date range label, drill-down to Costs *(TZ 2 partial, TZ 3 for costs)* +- [x] **PR #606** — monthly revenue chart drill-down, SalesList URL-state *(TZ 3 for revenue)* +- [x] **PR #607** — `/api/tenant/data-boundaries`, `‹ ›` arrow stepping with keyboard, `` shared component *(TZ 2 partial, TZ 6 partial)* +- [x] **PR #609** — tenant feature flags, `[RequireFeatureFlag]` middleware, `FeatureFlagGate`, Budget hidden by flag *(TZ 4, TZ 5)* +- [x] **PR #610** — super-admin foundation: `IsSuperAdmin`, JWT claims (`is_super_admin`, `mfa_verified`), TOTP MFA, `AdminController` with `IgnoreQueryFilters()`, `/admin/tenants` + `/admin/tenants/:id` pages, audit log table *(TZ 14 foundation)* +- [x] **PR #611** — super-admin integration tests (8 scenarios), `TestAuthHandler` extended with opt-in headers *(test debt from #610 closed)* +- [x] **PR #612** — full Season model (Seasons table with StartDate/EndDate/IsCurrent, tenant-admin `/api/seasons/*` CRUD, super-admin `/api/admin/tenants/{id}/seasons/*` CRUD, partial unique index for single current season, 3 default seasons seeded per existing tenant, Dashboard consumes real season list via arrow navigation) *(TZ 2 closed)* + +--- + +## In progress + +- [ ] **PR #613 — Currency system** *(TZ 8.2)* + - `UserPreferences.PreferredCurrency` (UAH/USD/EUR, default UAH) + - `ExchangeRate` table (Code, Date, RateToUah), PK (Code, Date) + - `NbuCurrencyService` + cron 06:00 Kyiv, backfill from 2024-01-01 + - `useFormatCurrency()` hook, settings UI in Профіль → Валюта + - Fallback: last stored rate on NBU failure; weekend/holiday → previous business day + - Exports: currency header with NBU rate on export date + +--- + +## Upcoming (in order — do not reorder without approval) + +- [ ] **PR #614 — Super-admin advanced + impersonation** *(TZ 14 remainder)* + - Impersonation: 60min TTL, mandatory reason, red banner in UI, email to target user, rate limit 3/day per (admin, target) pair + - Forbidden actions in impersonation: password/email change, API keys write scope, billing ops, tenant export + - `/admin/users` global search, impersonate action + - `/admin/audit-log` global view with filters (tenant, user, action type, period) + - `/admin/system` (queue/jobs health, storage, connections) + - `/admin/catalogs` (global reference data: crops, equipment types, units) + - `/admin/broadcast` (notification to all/selected tenants) + +- [ ] **PR #615 — Warehouse: grain receipt + inventory** *(TZ 9, TZ 10)* + - `/grain-storages` "Прийняти зерно" button: full form, creates GrainReceipt + GrainBatch + GrainBatchPlacement + StockLedgerEntry, recalculates StockBalance + - Verify `TransferGrainHandler` updates `GrainBatchPlacement` (known bug) + - `/inventory` full cycle: Draft → InProgress → Completed/Cancelled + - Inline editing of Counted column, auto-calculated Difference with color coding + - Progress indicator: "Підраховано X з Y (Z%)" + - Completion creates `InventoryAdjustment` ledger entries per diff, invalidates `StockBalance` cache + - Session history with read-only view after completion + +- [ ] **PR #616 — Notifications center** *(TZ 12)* + - Dropdown layout fix (min-width 400px, title inline) + - dayjs relativeTime with `uk` locale, proper thresholds (`X хв тому`, `X дн. тому`, `12 берез.`, etc.) + - Mark-all-read, clear-read working + - Deep-link click → navigates to target + marks read + - `/notifications` full page with filters (type, status, period), infinite scroll + - SignalR `NotificationHub` for real-time push + - Backend fix: `NotificationService` uses empty Identity Roles tables — migrate to enum role on `AppUser` + - Triggers verified: overdue op, tech repair, low fuel, low/over storage, sale completed, job failure + +- [ ] **PR #617 — Demo seeder + Mobile + PWA** *(TZ 11, TZ 13)* + - `Tools/DemoSeeder` idempotent: fills all core modules with connected Ukrainian-realistic data (6–12 months history) + - Auto-enables all optional feature flags for demo tenant after seed + - Mobile audit via Playwright at 390×844 and 360×640 across all routes + - Drawer sidebar, bottom nav (Головна / Поля / Склад / Техніка / Ще) + - Tables → Cards on fields, tech, operations, grain-storages, grain-batches, expenses, sales, personnel, rent-payments + - Forms: proper input types, sticky submit, 48px buttons + - Modals → bottom sheets on mobile + - PWA: manifest, service worker (Workbox), stale-while-revalidate assets, offline read-only for cached API data + +--- + +## Decisions locked (do not re-discuss, do not override without explicit approval in chat) + +**Currency** +- NBU JSON API: `https://bank.gov.ua/NBU_Exchange/exchange_site?start=YYYYMMDD&end=YYYYMMDD&valcode=USD&json` (and EUR) +- Base currency in DB is always UAH +- Conversion happens at presentation layer only +- `ExchangeRate (Code, Date, RateToUah)` with PK (Code, Date) +- Do NOT add `rate_at_transaction` to operation tables in this phase +- Weekend/holiday: fallback to previous business day's rate +- NBU unavailable: use last stored rate + log Warning + +**2FA** +- TOTP only (Otp.NET backend, otplib+qrcode frontend) +- No SMS, no email codes +- Mandatory for `IsSuperAdmin=true` accounts +- Optional for regular users, toggle in Налаштування → Безпека +- 10 single-use BCrypt-hashed backup codes +- Recovery on exhausted backup codes: manual reset by another super-admin with audit entry + +**Impersonation** +- Full login-as, NOT view-only +- Red persistent banner across entire UI, not dismissable, z-index 9999 +- 60min TTL, not renewable — must start new session +- Mandatory `reason` field (min 10 chars, free text) +- Email notification sent to target user on start +- Audit log: every mutation tagged with `impersonated_by` + `acted_as` +- Forbidden actions in impersonation: password change, email change, account deletion, API keys write scope, billing, full data export +- Rate limit: 3 sessions per (admin, target_user) per 24h +- ToS clause required for tenants + +**Feature flags** +- Per-tenant only, no per-user overrides +- Hardcoded enum of flag keys in code (not user-defined) +- `TenantFeatureFlags (TenantId, FeatureKey, IsEnabled, UpdatedAt, UpdatedBy)` +- IMemoryCache TTL 60s, invalidate on write +- Disabled flag on sidebar → menu item NOT rendered (not grayed out) +- Disabled flag on route → 404 (not 403, don't reveal feature existence) + +**Optional flag keys (these are the ones gated per tenant):** +``` +budget +pnl_by_fields +analytics.marginality +analytics.season_comparison +analytics.break_even +analytics.field_efficiency +analytics.resource_usage +analytics.expense_analytics +analytics.sales_analytics +``` + +**Core modules — always on, never gated:** +- Головна, Виробництво, Склад і логістика, Техніка, Персонал +- Фінанси: Витрати, Продажі, Орендні платежі, Зарплата та паливо +- Налаштування + +**Theme** +- Dark mode only, light mode removed in PR #604 + +**Season** +- Real `Seasons` table with `StartDate`/`EndDate`, NOT hardcoded year-list (PR #612) +- Each tenant can have custom seasonal boundaries (crops differ) + +--- + +## Deferred / cut from roadmap + +- **Billing module** — no monetization yet, no payment integration planned. Feature flags already demonstrate tenant-level plan differentiation. Revisit post-roadmap. +- **Per-user feature flags / beta testers list** — not this phase. Current stage is product stability, not A/B infrastructure. +- **`rate_at_transaction` on operations** — only Sales/PurchaseContracts will need frozen rates eventually, not all operations. Defer until concrete business case. + +--- + +## Technical debt (to schedule) + +- **Feature flags legacy fallback**: current behavior is "no records = all enabled" (protects existing tenants from PR #609 migration). Before prod has paying clients: run backfill to write explicit `IsEnabled=true` for all tenants, then switch fallback to "no records = all disabled". +- **MailKit 4.15.1** — moderate severity CVE (`GHSA-9j88-vvj5-vhgr`). Upgrade to latest in a chore PR. +- **`.gitignore` has `.mcp.json` listed 3 times** — cosmetic, clean up when touching the file next. +- **P.6 totals audit incomplete** — PR #607 migrated `/expenses` ВСЬОГО and `/sales` totalRevenue. Other pages not verified: `/rent-payments`, `/salary-fuel`, `/pnl-by-fields`. Schedule as chore PR or fold into PR #617 demo prep. + +--- + +## Agent protocol + +When starting a new PR: + +1. Read this file and `docs/TZ.md`. +2. Verify `git log --oneline -20` on main matches the "Completed" section here. +3. Start the PR listed under "In progress". Do not skip ahead. Do not redo closed work. +4. If roadmap and codebase disagree, STOP and ask in chat — do not guess. +5. Decisions in "Decisions locked" are final; do not re-discuss. +6. Squash-merge to main with a clean commit message. +7. After merge: update this file — move completed item to "Completed", promote next item from "Upcoming" into "In progress". Update status markers in `docs/TZ.md` for affected points. +8. Commit the roadmap update in the same PR or as first chore commit of the next PR. diff --git a/docs/TZ.md b/docs/TZ.md new file mode 100644 index 00000000..df328dd2 --- /dev/null +++ b/docs/TZ.md @@ -0,0 +1,246 @@ +# AgroPlatform TZ — Original audit findings with fix specifications + +> Reference document. Captures the original scope of the audit-driven fix cycle. +> This file does NOT define PR order — see `docs/ROADMAP.md` for that. +> Use this file to understand business context and acceptance criteria for each point. +> +> Status markers: +> - `[CLOSED in PR #X]` — fully shipped, no remaining work +> - `[PARTIALLY CLOSED in PR #X; rest in PR #Y]` — partial scope shipped, rest scheduled +> - `[IN PROGRESS in PR #X]` — active work +> - `[PLANNED for PR #X]` — scheduled, not started +> - (no marker) — not yet scheduled + +--- + +## Project context + +- Stack: .NET 8 + React 18 + TypeScript + PostgreSQL/PostGIS + Docker +- Architecture: Clean Architecture / CQRS / MediatR, multi-tenancy via `X-Tenant-Id` + EF Core query filters +- Production: `agrotech-usa.com`, DigitalOcean droplet `64.226.83.68` +- Demo mode currently open for all — banner "Демо-режим" must remain +- UI language: Ukrainian + +--- + +## ПУНКТ 1 — Корневий маршрут веде на лендинг, а не на login `[CLOSED in PR #604]` + +**Problem:** Opening `https://agrotech-usa.com/` redirected to `/login`. Should be: unauthenticated → AgroHero landing; authenticated → `/dashboard`. + +**Shipped:** Root route renders landing for unauthenticated visitors (including public demo mode). `usePublicDemoAutoLogin` skips auto-login on `/`. In-app CTAs trigger login as before. + +--- + +## ПУНКТ 2 — Перемикач періоду на дашборді показує конкретні дати `[CLOSED in PR #605, #607, #612]` + +**Problem:** Dashboard buttons `День / Тиждень / Місяць / Сезон` didn't show which period was active. + +**Shipped in #605:** `?period=` URL query param on `/dashboard`; monospace label under segmented control shows resolved date range. +**Shipped in #607:** `‹ ›` anchor-date stepping with keyboard shortcuts (`←`/`→`), boundary-aware disable via `/api/tenant/data-boundaries`. +**Shipped in #612:** Full `Seasons` table (Id, TenantId, Code, Name, StartDate, EndDate, IsCurrent). Dashboard season arrows iterate the real season list per tenant, label uses the stored Season name, and arrow boundaries are driven by `StartDate`/`EndDate`. Legacy `/api/seasons` year-list endpoint replaced with tenant-admin CRUD; super-admin CRUD under `/api/admin/tenants/{tenantId}/seasons`. Data migration seeded 3 default seasons (2023/2024, 2024/2025, 2025/2026) per existing tenant. + +--- + +## ПУНКТ 3 — Клік по графіку "Фінансовий огляд" → drill-down `[CLOSED in PR #605, #606]` + +**Problem:** Financial chart points were not clickable; could not see what composed a spike. + +**Shipped in #605:** Click on chart point navigates to `/economics/costs?from=…&to=…` with RangePicker pre-filled. +**Shipped in #606:** Same pattern for monthly revenue bar chart on `/sales/analytics` → navigates to `/sales`. + +--- + +## ПУНКТ 4 — Прибрати "Бюджет" з sidebar `[CLOSED in PR #609]` + +**Problem:** Budget module has no real business logic; market too volatile. + +**Shipped:** Budget hidden via feature flag (default off for new tenants). Code preserved under feature-flag gate. Direct route access returns 404 when flag disabled. + +--- + +## ПУНКТ 5 — Feature flags per tenant + super-admin UI for management `[CLOSED in PR #609 backend; super-admin UI in PR #610]` + +**Problem:** Not every company needs every module. Need per-tenant module toggles. + +**Always on (core):** Головна, Виробництво, Склад і логістика, Техніка, Персонал, Фінанси (Витрати, Продажі, Орендні платежі, Зарплата та паливо), Налаштування. + +**Optional (per-tenant toggle):** +- `budget` +- `pnl_by_fields` +- `analytics.marginality`, `analytics.season_comparison`, `analytics.break_even`, `analytics.field_efficiency`, `analytics.resource_usage`, `analytics.expense_analytics`, `analytics.sales_analytics` + +**Shipped in #609:** `TenantFeatureFlags` table, `IFeatureFlagService` with `IMemoryCache` TTL 60s, `[RequireFeatureFlag]` middleware (returns 404), `FeatureFlagGate` frontend component, `/api/me` payload includes features map. +**Shipped in #610:** Super-admin UI for per-tenant toggles at `/admin/tenants/:id`. +**Technical debt:** Legacy fallback "no records = all enabled" protects existing tenants. Before production has paying clients, backfill explicit flags and switch fallback to "all disabled". + +--- + +## ПУНКТ 6 — Карточка "ВСЬОГО" на /expenses + аудит всіх totals-карток `[PARTIALLY CLOSED in PR #607; remaining pages pending]` + +**Problem:** `/expenses` ВСЬОГО card was empty; similar issue on other pages. + +**Shipped in #607:** New `` shared component with highlight variant. `MaterialKpiCards` refactored to use it. `SalesList.totalRevenue` migrated. +**Remaining:** Verify and migrate totals on `/rent-payments`, `/salary-fuel`, `/pnl-by-fields`. Playwright sweep across all pages with totals cards. May fold into PR #617 demo prep or separate chore PR. + +--- + +## ПУНКТ 7 — Прибрати блок "Витрати план/факт за категоріями" з /expenses `[CLOSED in PR #603]` + +**Shipped:** Plan/Fact card removed from `/expenses`. Code preserved but not rendered. + +--- + +## ПУНКТ 8 — Прибрати light mode, додати selectable currency `[8.1 CLOSED in PR #604; 8.2 PLANNED for PR #613]` + +### 8.1 Light mode `[CLOSED in PR #604]` +**Shipped:** Sun/Moon toggle removed from topbar; `themeStore` locked to `'dark'`; `ConfigProvider` always uses dark theme. + +### 8.2 Currency selector `[PLANNED for PR #613]` +**Scope:** +- `UserPreferences.PreferredCurrency` (UAH/USD/EUR, default UAH) +- `ExchangeRate` table with PK (Code, Date) +- `NbuCurrencyService` + cron 06:00 Kyiv +- Backfill script `Tools/NbuBackfill` from 2024-01-01 +- `useFormatCurrency()` hook, settings UI in Профіль → Валюта +- Fallback: last stored rate on NBU failure; weekend/holiday → previous business day +- Exports: currency header with NBU rate on export date +- Base currency in DB always UAH; conversion at presentation layer only +- Do NOT add `rate_at_transaction` to operation tables in this phase + +--- + +## ПУНКТ 9 — Кнопка "Прийняти зерно" на /grain-storages `[PLANNED for PR #615]` + +**Problem:** "Прийняти зерно" button does nothing on click. + +**Scope:** +- Open modal with full grain receipt form: date, crop, warehouse, batch number (auto-generated), quantity, moisture/trash %, source (field/counterparty), driver/TTN/vehicle +- Validation: quantity > 0, warehouse capacity check, field must be in current season if source = field +- Creates `GrainReceipt` + `GrainBatch` + `GrainBatchPlacement` + `StockLedgerEntry` of type `GrainReceipt` +- Recalculates `StockBalance` +- Known bug from prior audit: verify `TransferGrainHandler` correctly updates `GrainBatchPlacement` + +--- + +## ПУНКТ 10 — Фікс модуля /inventory (Інвентаризація) `[PLANNED for PR #615]` + +**Problem:** Inventory module doesn't work end-to-end. Counted/Difference columns empty, progress shows garbage, session can't be completed. + +**Scope — full lifecycle:** +- Session states: `Draft → InProgress → Completed/Cancelled` +- Inline editing of Counted column, auto-calculated Difference with color coding (red/amber/green) +- Comment dropdown: Недостача / Списання / Помилка обліку / Пересорт / Інше +- Progress indicator: `Підраховано X з Y (Z%)` + bar +- Completion creates `StockLedgerEntry` of type `InventoryAdjustment` per non-zero diff +- `StockBalance` cache invalidated after completion +- Cancel flow without ledger changes +- Read-only view after completion +- History list with filters + +--- + +## ПУНКТ 11 — Демо-seed: заповнити дані по всіх модулях `[PLANNED for PR #617]` + +**Problem:** Demo tenant has empty modules (e.g., `/salary-fuel`). Cannot show full functionality to investors. + +**Scope:** Idempotent `Tools/DemoSeeder` fills all core + enabled optional modules with connected Ukrainian-realistic data over 6–12 months. Auto-enables all optional feature flags for demo tenant after seed. + +**Data coverage requirements:** +- Organization: ТОВ "Демо-Агро" in Poltava region +- Fields: 83 fields (already seeded), add crop rotation for 2024/25/26 +- Seasons: 2024 (closed), 2025 (closed), 2026 (active) +- Warehouses: grain storages with batches of wheat/corn/sunflower/rapeseed/soy; inventory items (КАС-32, NPK, seeds, chemicals, fuel, parts) +- Equipment: 15–20 units (John Deere, Case IH, МТЗ, Claas, КамАЗ) +- Personnel: 30–50 employees with real positions and Ukrainian names +- Operations: 300–500 ops linking fields/equipment/operators/stock +- Expenses: all 6 categories populated +- Sales: 8–12 contracts with real traders (Kernel, Nibulon, Bunge) +- Rent payments: 150–300 landholders with contracts and partial payouts +- Salary & fuel: 3–6 months of timesheets and fuel distributions +- Notifications: 10–15 realistic triggers +- API keys: 1–2 demo keys with different scopes + +**Idempotency:** Use `tenant.Code = 'DEMO'` and `entity.SeedKey` to prevent duplication on rerun. + +--- + +## ПУНКТ 12 — Центр сповіщень: фікс UI і логіки `[PLANNED for PR #616]` + +**Problem:** Dropdown title renders vertically per letter, time shows "2227h ago" in English, actions may not work, read/unread visually indistinguishable. + +**Scope:** +- Dropdown min-width 400px (mobile: full-width bottom sheet) +- Title "Сповіщення · X нових" in one line +- Color border by severity (info=blue, warning=amber, critical=red, success=green) +- Unread indicator dot, read items dimmed 60% +- dayjs `relativeTime` with `uk` locale: `щойно`, `X хв тому`, `X год тому`, `вчора о HH:mm`, `X дн. тому`, `12 берез.`, `DD MMM YYYY` +- Actions: `Прочитати все` (PATCH mark-all-read), `Очистити прочитані` (DELETE read only) +- Click → navigate to `targetUrl` + mark as read +- Hover → individual "×" for single delete +- Dropdown shows first 10, footer link `Усі сповіщення →` leads to `/notifications` +- `/notifications` page: filters (type/status/period), infinite scroll or pagination by 50 +- SignalR `NotificationHub` for real-time push, increments badge counter +- **Backend fix:** `NotificationService` currently uses empty Identity Roles tables. Migrate to enum role on `AppUser`; targeting via `NotificationRecipient` table or `target_role` enum with user selection from tenant. +- Triggers to verify working: overdue operation, tech in repair, low fuel, low/over storage, sale completed, fuel issue, background job failure + +--- + +## ПУНКТ 13 — Повний mobile audit і переробка `[PLANNED for PR #617]` + +**Problem:** Mobile version not designed for field use. + +**Scope:** +- Playwright audit at 390×844 (iPhone 14) and 360×640 (Android baseline) across all routes +- Screenshots saved to `/audit/mobile/{route-slug}/` +- **AppShell:** sidebar → drawer with overlay, toggled by hamburger, closable by swipe-left or overlay tap. Top bar sticky 56px. Bottom navigation bar (breakpoint <768px) with 5 items: Головна / Поля / Склад / Техніка / Ще +- **Tables → Cards** on mobile: `/fields`, `/tech`, `/operations`, `/grain-storages`, `/grain-batches`, `/expenses`, `/sales`, `/personnel`, `/rent-payments`. Action menu as kebab → bottom sheet. +- **Forms:** full width, `type="number"` with `inputmode="decimal"`, `type="tel"`, `type="date"`, 16px inputs (no iOS zoom), sticky 48px submit button +- **Dashboard:** KPI cards single-column, charts with tap tooltips, Leaflet with larger zoom controls +- **Modals → bottom sheets** on mobile with drag handle +- **Search/filters:** dedicated "Фільтри" button → full-screen panel +- **PWA:** `manifest.webmanifest` with icons (192, 512), theme color `#0a0a0a`. Service worker (Workbox): stale-while-revalidate for assets, cache read-only API (`/api/fields`, `/api/operations?status=active`, `/api/dashboard`) with TTL 5min. Offline banner when no connection. +- **Acceptance:** Lighthouse mobile ≥ 90 (Performance/Accessibility/Best Practices). Install to home screen works on iOS Safari + Android Chrome. + +--- + +## ПУНКТ 14 — Супер-адмін має бути реально супер-адміном `[FOUNDATION CLOSED in PR #610; advanced features in PR #614]` + +**Problem:** Previous "super-admin" role was tenant-scoped, couldn't see other tenants. + +### Foundation `[CLOSED in PR #610]` +**Shipped:** +- `IsSuperAdmin` boolean on `AppUser` (global, outside tenant roles) +- JWT claim `is_super_admin`, separate claim `mfa_verified` +- `[SuperAdminRequired]` middleware returning 403 + `X-Mfa-Required` header when MFA not set up +- `AdminController` with 5 endpoints, all using `IgnoreQueryFilters()` for cross-tenant access +- TOTP MFA mandatory for super-admin (Otp.NET, 10 BCrypt-hashed backup codes, ±1 step tolerance) +- `SuperAdminAuditLog` table + `SuperAdminAuditService`, entries on every mutation +- Pages: `/admin/tenants`, `/admin/tenants/:id` with Features tab +- 8 integration tests (PR #611) + +### Advanced features `[PLANNED for PR #614]` +**Scope:** +- **Impersonation:** + - `POST /api/admin/impersonate` with mandatory `reason` field + - 60min TTL, not renewable + - Red persistent banner across UI during impersonation + - Email notification to target user on start + - Rate limit: 3/day per (admin, target_user) pair + - Forbidden actions: password/email change, API keys with write scope, billing, full data export + - Audit: every mutation tagged `impersonated_by` + `acted_as` +- **`/admin/users`** — global search across tenants, impersonate button +- **`/admin/audit-log`** — global view with filters (tenant, user, action type, period), CSV export +- **`/admin/system`** — queue/jobs health, storage usage, active SignalR connections, error rate +- **`/admin/catalogs`** — manage global reference data (crops, equipment types, units, document types) +- **`/admin/broadcast`** — send notifications to all or selected tenants, with history + +--- + +## Notes on execution rules + +- Cannot skip ahead in the roadmap order +- Cannot redo closed work +- Decisions in `docs/ROADMAP.md` "Decisions locked" section are final +- Ask in chat before re-interpreting scope +- Each PR: typecheck + tests green before commit; squash-merge to main +- Update `ROADMAP.md` and this file after every merge diff --git a/docs/plan.md b/docs/plan.md index fb80780e..8b5e0969 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -88,5 +88,5 @@ Coexistence with the existing role-based super-admin: ## Upcoming - PR #611 — multi-tenant DNS / org-level routing. -- PR #612 — billing + plan enforcement. +- PR #612 — **Season model (ТЗ #2)**: replace the legacy year-list `/api/seasons` endpoint with a real tenant-scoped `Season` entity (Code, Name, StartDate, EndDate, IsCurrent) so the dashboard navigates through actual crop cycles (Aug 1 → Jul 31) instead of calendar years. Adds tenant-admin CRUD at `/api/seasons/*` and super-admin CRUD at `/api/admin/tenants/{id}/seasons/*` (audited). Migration seeds three default seasons (2023/2024, 2024/2025, 2025/2026-current) per existing tenant, idempotent. Breaking API change for the frontend: response shape `int[]` → `SeasonDto[]`. Billing/plan enforcement is deferred (feature flags already differentiate plans — see PR #609). - PR #613 — super-admin phase 2 (users, audit UI, billing, impersonation). diff --git a/frontend/src/api/tenants.ts b/frontend/src/api/tenants.ts index 01440128..cd41e112 100644 --- a/frontend/src/api/tenants.ts +++ b/frontend/src/api/tenants.ts @@ -24,6 +24,15 @@ export interface TenantDataBoundariesDto { maxOperationDate: string | null; } +export interface SeasonDto { + id: string; + code: string; + name: string; + startDate: string; // ISO date (YYYY-MM-DD) + endDate: string; + isCurrent: boolean; +} + export const getTenants = () => apiClient.get('/api/tenants').then((r) => r.data); @@ -37,4 +46,4 @@ export const getTenantDataBoundaries = () => apiClient.get('/api/tenant/data-boundaries').then((r) => r.data); export const getSeasons = () => - apiClient.get('/api/seasons').then((r) => r.data); + apiClient.get('/api/seasons').then((r) => r.data); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 1861828c..c0c4e8fb 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -116,11 +116,17 @@ export default function Dashboard() { return periodToRange(period); }, [period, resolvedWindow]); - const sortedSeasons = useMemo(() => (seasons ?? []).slice().sort((a, b) => a - b), [seasons]); + const sortedSeasons = useMemo( + () => (seasons ?? []).slice().sort((a, b) => a.startDate.localeCompare(b.startDate)), + [seasons] + ); const resolvedRangeLabel = useMemo(() => { if (period === 'season') { - if (hasExplicitRange && fromDate) return String(fromDate.year()); + const match = hasExplicitRange && fromDate + ? sortedSeasons.find((s) => dayjs(s.startDate).isSame(fromDate, 'day')) + : sortedSeasons.find((s) => s.isCurrent) ?? sortedSeasons[sortedSeasons.length - 1]; + if (match) return match.name; return t.dashboard.allTime ?? 'Весь час'; } @@ -161,56 +167,54 @@ export default function Dashboard() { }; }, [period, resolvedWindow]); - const currentSeasonYear = useMemo(() => { - if (period !== 'season') return null; - if (hasExplicitRange && fromDate) return fromDate.year(); - if (sortedSeasons.length > 0) return sortedSeasons[sortedSeasons.length - 1]; - return dayjs().year(); + const activeSeason = useMemo(() => { + if (period !== 'season' || sortedSeasons.length === 0) return null; + if (hasExplicitRange && fromDate) { + const match = sortedSeasons.find((s) => dayjs(s.startDate).isSame(fromDate, 'day')); + if (match) return match; + } + return sortedSeasons.find((s) => s.isCurrent) ?? sortedSeasons[sortedSeasons.length - 1]; }, [period, hasExplicitRange, fromDate, sortedSeasons]); - const disableStepPrev = useMemo(() => { - if (!minBound) return true; + const activeSeasonIndex = useMemo( + () => (activeSeason ? sortedSeasons.findIndex((s) => s.id === activeSeason.id) : -1), + [activeSeason, sortedSeasons] + ); + const disableStepPrev = useMemo(() => { if (period === 'season') { - if (!currentSeasonYear) return true; - const idx = sortedSeasons.indexOf(currentSeasonYear); - return idx <= 0; + return activeSeasonIndex <= 0; } - + if (!minBound) return true; const prev = shiftWindow(-1); if (!prev) return true; return prev.from.isBefore(minBound); - }, [minBound, period, currentSeasonYear, sortedSeasons, shiftWindow]); + }, [minBound, period, activeSeasonIndex, shiftWindow]); const disableStepNext = useMemo(() => { - if (!maxBound) return true; - if (period === 'season') { - if (!currentSeasonYear) return true; - const idx = sortedSeasons.indexOf(currentSeasonYear); - return idx < 0 || idx >= sortedSeasons.length - 1; + return activeSeasonIndex < 0 || activeSeasonIndex >= sortedSeasons.length - 1; } - + if (!maxBound) return true; const next = shiftWindow(1); if (!next) return true; return next.to.isAfter(maxBound); - }, [maxBound, period, currentSeasonYear, sortedSeasons, shiftWindow]); + }, [maxBound, period, activeSeasonIndex, sortedSeasons.length, shiftWindow]); const stepPeriod = useCallback((step: -1 | 1) => { if (period === 'season') { - if (!currentSeasonYear || sortedSeasons.length === 0) return; - const currentIndex = sortedSeasons.indexOf(currentSeasonYear); - const nextIndex = currentIndex + step; + if (activeSeasonIndex < 0) return; + const nextIndex = activeSeasonIndex + step; if (nextIndex < 0 || nextIndex >= sortedSeasons.length) return; - const targetYear = sortedSeasons[nextIndex]; - setRange(dayjs(`${targetYear}-01-01`), dayjs(`${targetYear}-12-31`)); + const target = sortedSeasons[nextIndex]; + setRange(dayjs(target.startDate), dayjs(target.endDate)); return; } const next = shiftWindow(step); if (!next) return; setRange(next.from, next.to); - }, [period, currentSeasonYear, sortedSeasons, setRange, shiftWindow]); + }, [period, activeSeasonIndex, sortedSeasons, setRange, shiftWindow]); const handleStepPrev = useCallback(() => { if (disableStepPrev) return; diff --git a/src/AgroPlatform.Api/Controllers/AdminController.cs b/src/AgroPlatform.Api/Controllers/AdminController.cs index 4dbd900a..f98e940b 100644 --- a/src/AgroPlatform.Api/Controllers/AdminController.cs +++ b/src/AgroPlatform.Api/Controllers/AdminController.cs @@ -1,6 +1,7 @@ using AgroPlatform.Api.SuperAdmin; using AgroPlatform.Application.Common.Interfaces; using AgroPlatform.Domain.FeatureFlags; +using AgroPlatform.Domain.Seasons; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -214,4 +215,132 @@ public async Task ListAuditLog( return Ok(new { items, total, page, pageSize }); } + + // ============================================================================================= + // Seasons (platform super-admin scope). Bypasses tenant filter, audits every mutation. + // ============================================================================================= + + public record AdminSeasonDto(Guid Id, string Code, string Name, DateOnly StartDate, DateOnly EndDate, bool IsCurrent); + public record AdminCreateSeasonRequest(string Code, string Name, DateOnly StartDate, DateOnly EndDate, bool IsCurrent); + public record AdminUpdateSeasonRequest(string Code, string Name, DateOnly StartDate, DateOnly EndDate); + + [HttpGet("tenants/{tenantId:guid}/seasons")] + public async Task ListTenantSeasons(Guid tenantId, CancellationToken ct) + { + var items = await _db.Seasons + .IgnoreQueryFilters() + .Where(s => s.TenantId == tenantId && !s.IsDeleted) + .OrderBy(s => s.StartDate) + .Select(s => new AdminSeasonDto(s.Id, s.Code, s.Name, s.StartDate, s.EndDate, s.IsCurrent)) + .ToListAsync(ct); + return Ok(items); + } + + [HttpPost("tenants/{tenantId:guid}/seasons")] + public async Task CreateTenantSeason(Guid tenantId, [FromBody] AdminCreateSeasonRequest req, CancellationToken ct) + { + if (req.EndDate <= req.StartDate) return BadRequest(new { error = "EndDate must be after StartDate." }); + if (!await _db.Tenants.IgnoreQueryFilters().AnyAsync(t => t.Id == tenantId, ct)) + return NotFound(); + if (await _db.Seasons.IgnoreQueryFilters().AnyAsync(s => s.TenantId == tenantId && s.Code == req.Code && !s.IsDeleted, ct)) + return Conflict(new { error = "Season with this code already exists." }); + + if (req.IsCurrent) + { + var currents = await _db.Seasons.IgnoreQueryFilters() + .Where(s => s.TenantId == tenantId && s.IsCurrent && !s.IsDeleted).ToListAsync(ct); + foreach (var c in currents) c.IsCurrent = false; + if (currents.Count > 0) await _db.SaveChangesAsync(ct); + } + + var season = new Season + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Code = req.Code, + Name = req.Name, + StartDate = req.StartDate, + EndDate = req.EndDate, + IsCurrent = req.IsCurrent, + }; + _db.Seasons.Add(season); + await _db.SaveChangesAsync(ct); + + await _audit.LogAsync("season.create", nameof(Season), season.Id.ToString(), before: null, + after: new AdminSeasonDto(season.Id, season.Code, season.Name, season.StartDate, season.EndDate, season.IsCurrent), ct); + + return Created($"/api/admin/tenants/{tenantId}/seasons/{season.Id}", + new AdminSeasonDto(season.Id, season.Code, season.Name, season.StartDate, season.EndDate, season.IsCurrent)); + } + + [HttpPut("tenants/{tenantId:guid}/seasons/{id:guid}")] + public async Task UpdateTenantSeason(Guid tenantId, Guid id, [FromBody] AdminUpdateSeasonRequest req, CancellationToken ct) + { + if (req.EndDate <= req.StartDate) return BadRequest(new { error = "EndDate must be after StartDate." }); + var season = await _db.Seasons.IgnoreQueryFilters() + .FirstOrDefaultAsync(s => s.Id == id && s.TenantId == tenantId && !s.IsDeleted, ct); + if (season is null) return NotFound(); + + if (season.Code != req.Code && + await _db.Seasons.IgnoreQueryFilters().AnyAsync(s => s.TenantId == tenantId && s.Code == req.Code && s.Id != id && !s.IsDeleted, ct)) + return Conflict(new { error = "Season with this code already exists." }); + + var before = new AdminSeasonDto(season.Id, season.Code, season.Name, season.StartDate, season.EndDate, season.IsCurrent); + season.Code = req.Code; + season.Name = req.Name; + season.StartDate = req.StartDate; + season.EndDate = req.EndDate; + await _db.SaveChangesAsync(ct); + + var after = new AdminSeasonDto(season.Id, season.Code, season.Name, season.StartDate, season.EndDate, season.IsCurrent); + await _audit.LogAsync("season.update", nameof(Season), season.Id.ToString(), before, after, ct); + return NoContent(); + } + + [HttpPost("tenants/{tenantId:guid}/seasons/{id:guid}/set-current")] + public async Task SetCurrentTenantSeason(Guid tenantId, Guid id, CancellationToken ct) + { + var target = await _db.Seasons.IgnoreQueryFilters() + .FirstOrDefaultAsync(s => s.Id == id && s.TenantId == tenantId && !s.IsDeleted, ct); + if (target is null) return NotFound(); + + var before = new AdminSeasonDto(target.Id, target.Code, target.Name, target.StartDate, target.EndDate, target.IsCurrent); + + var currents = await _db.Seasons.IgnoreQueryFilters() + .Where(s => s.TenantId == tenantId && s.IsCurrent && s.Id != id && !s.IsDeleted).ToListAsync(ct); + if (currents.Count > 0) + { + foreach (var c in currents) c.IsCurrent = false; + await _db.SaveChangesAsync(ct); + } + if (!target.IsCurrent) + { + target.IsCurrent = true; + await _db.SaveChangesAsync(ct); + } + + var after = new AdminSeasonDto(target.Id, target.Code, target.Name, target.StartDate, target.EndDate, target.IsCurrent); + await _audit.LogAsync("season.set-current", nameof(Season), target.Id.ToString(), before, after, ct); + return NoContent(); + } + + [HttpDelete("tenants/{tenantId:guid}/seasons/{id:guid}")] + public async Task DeleteTenantSeason(Guid tenantId, Guid id, [FromQuery] bool force, CancellationToken ct) + { + var season = await _db.Seasons.IgnoreQueryFilters() + .FirstOrDefaultAsync(s => s.Id == id && s.TenantId == tenantId && !s.IsDeleted, ct); + if (season is null) return NotFound(); + + if (season.IsCurrent && !force) + return Conflict(new { error = "Cannot delete the current season without force=true." }); + + var before = new AdminSeasonDto(season.Id, season.Code, season.Name, season.StartDate, season.EndDate, season.IsCurrent); + + season.IsDeleted = true; + season.DeletedAtUtc = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + + await _audit.LogAsync("season.delete", nameof(Season), season.Id.ToString(), before, after: null, ct); + return NoContent(); + } } diff --git a/src/AgroPlatform.Api/Controllers/SeasonsController.cs b/src/AgroPlatform.Api/Controllers/SeasonsController.cs index 002aedcc..756a632f 100644 --- a/src/AgroPlatform.Api/Controllers/SeasonsController.cs +++ b/src/AgroPlatform.Api/Controllers/SeasonsController.cs @@ -1,10 +1,18 @@ using AgroPlatform.Application.Common.Interfaces; +using AgroPlatform.Domain.Enums; +using AgroPlatform.Domain.Seasons; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace AgroPlatform.Api.Controllers; +/// +/// Tenant-scoped season management. Reads are available to any authenticated member +/// of the tenant; mutations require CompanyAdmin (or platform super-admin). +/// Replaces the legacy integer year-list endpoint with a proper typed Season model +/// (breaking change — see ROADMAP PR #612). +/// [ApiController] [Route("api/seasons")] [Authorize] @@ -20,34 +28,196 @@ public SeasonsController(IAppDbContext db, ICurrentUserService currentUser) _currentUser = currentUser; } + public record SeasonDto( + Guid Id, + string Code, + string Name, + DateOnly StartDate, + DateOnly EndDate, + bool IsCurrent); + + public record CreateSeasonRequest(string Code, string Name, DateOnly StartDate, DateOnly EndDate, bool IsCurrent); + + public record UpdateSeasonRequest(string Code, string Name, DateOnly StartDate, DateOnly EndDate); + + private bool IsTenantAdmin => + _currentUser.IsSuperAdmin || _currentUser.Role == UserRole.CompanyAdmin; + + private static SeasonDto ToDto(Season s) => + new(s.Id, s.Code, s.Name, s.StartDate, s.EndDate, s.IsCurrent); + [HttpGet] - [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] - public async Task List(CancellationToken cancellationToken) + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task List(CancellationToken ct) + { + var tenantId = _currentUser.TenantId; + var items = await _db.Seasons + .Where(s => s.TenantId == tenantId) + .OrderBy(s => s.StartDate) + .Select(s => new SeasonDto(s.Id, s.Code, s.Name, s.StartDate, s.EndDate, s.IsCurrent)) + .ToListAsync(ct); + return Ok(items); + } + + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(SeasonDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById(Guid id, CancellationToken ct) + { + var tenantId = _currentUser.TenantId; + var season = await _db.Seasons.FirstOrDefaultAsync(s => s.Id == id && s.TenantId == tenantId, ct); + if (season is null) return NotFound(); + return Ok(ToDto(season)); + } + + [HttpGet("current")] + [ProducesResponseType(typeof(SeasonDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetCurrent(CancellationToken ct) + { + var tenantId = _currentUser.TenantId; + var season = await _db.Seasons + .Where(s => s.TenantId == tenantId && s.IsCurrent) + .Select(s => new SeasonDto(s.Id, s.Code, s.Name, s.StartDate, s.EndDate, s.IsCurrent)) + .FirstOrDefaultAsync(ct); + if (season is null) return NotFound(); + return Ok(season); + } + + [HttpPost] + [ProducesResponseType(typeof(SeasonDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Create([FromBody] CreateSeasonRequest req, CancellationToken ct) + { + if (!IsTenantAdmin) return Forbid(); + if (string.IsNullOrWhiteSpace(req.Code)) return BadRequest(new { error = "Code is required." }); + if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest(new { error = "Name is required." }); + if (req.Code.Length > 16) return BadRequest(new { error = "Code must be 16 characters or fewer." }); + if (req.Name.Length > 100) return BadRequest(new { error = "Name must be 100 characters or fewer." }); + if (req.EndDate <= req.StartDate) return BadRequest(new { error = "EndDate must be after StartDate." }); + + var tenantId = _currentUser.TenantId; + if (await _db.Seasons.AnyAsync(s => s.TenantId == tenantId && s.Code == req.Code, ct)) + return Conflict(new { error = "Season with this code already exists." }); + + if (req.IsCurrent) + { + var currents = await _db.Seasons.Where(s => s.TenantId == tenantId && s.IsCurrent).ToListAsync(ct); + foreach (var c in currents) c.IsCurrent = false; + if (currents.Count > 0) await _db.SaveChangesAsync(ct); + } + + var season = new Season + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Code = req.Code.Trim(), + Name = req.Name.Trim(), + StartDate = req.StartDate, + EndDate = req.EndDate, + IsCurrent = req.IsCurrent, + }; + _db.Seasons.Add(season); + await _db.SaveChangesAsync(ct); + + return CreatedAtAction(nameof(GetById), new { id = season.Id }, ToDto(season)); + } + + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Update(Guid id, [FromBody] UpdateSeasonRequest req, CancellationToken ct) + { + if (!IsTenantAdmin) return Forbid(); + if (string.IsNullOrWhiteSpace(req.Code)) return BadRequest(new { error = "Code is required." }); + if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest(new { error = "Name is required." }); + if (req.Code.Length > 16) return BadRequest(new { error = "Code must be 16 characters or fewer." }); + if (req.Name.Length > 100) return BadRequest(new { error = "Name must be 100 characters or fewer." }); + if (req.EndDate <= req.StartDate) return BadRequest(new { error = "EndDate must be after StartDate." }); + + var tenantId = _currentUser.TenantId; + var season = await _db.Seasons.FirstOrDefaultAsync(s => s.Id == id && s.TenantId == tenantId, ct); + if (season is null) return NotFound(); + + if (season.Code != req.Code && + await _db.Seasons.AnyAsync(s => s.TenantId == tenantId && s.Code == req.Code && s.Id != id, ct)) + return Conflict(new { error = "Season with this code already exists." }); + + season.Code = req.Code.Trim(); + season.Name = req.Name.Trim(); + season.StartDate = req.StartDate; + season.EndDate = req.EndDate; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/set-current")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetCurrent(Guid id, CancellationToken ct) + { + if (!IsTenantAdmin) return Forbid(); + + var tenantId = _currentUser.TenantId; + var target = await _db.Seasons.FirstOrDefaultAsync(s => s.Id == id && s.TenantId == tenantId, ct); + if (target is null) return NotFound(); + + // Clear any other IsCurrent=true rows first, then flip the target in a second + // SaveChanges. Two writes avoid transient violation of the partial unique index. + var currents = await _db.Seasons.Where(s => s.TenantId == tenantId && s.IsCurrent && s.Id != id).ToListAsync(ct); + if (currents.Count > 0) + { + foreach (var c in currents) c.IsCurrent = false; + await _db.SaveChangesAsync(ct); + } + + if (!target.IsCurrent) + { + target.IsCurrent = true; + await _db.SaveChangesAsync(ct); + } + + return NoContent(); + } + + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Delete(Guid id, [FromQuery] bool force, CancellationToken ct) { + if (!IsTenantAdmin) return Forbid(); + var tenantId = _currentUser.TenantId; + var season = await _db.Seasons.FirstOrDefaultAsync(s => s.Id == id && s.TenantId == tenantId, ct); + if (season is null) return NotFound(); + + // Current season can only be deleted by super-admin with explicit force=true. + if (season.IsCurrent && !(force && _currentUser.IsSuperAdmin)) + return Conflict(new { error = "Cannot delete the current season. Set another season as current first." }); + + var startDt = season.StartDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + var endDt = season.EndDate.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); + var hasCosts = await _db.CostRecords.AnyAsync(x => x.TenantId == tenantId && x.Date >= startDt && x.Date <= endDt, ct); + var hasSales = await _db.Sales.AnyAsync(x => x.TenantId == tenantId && x.Date >= startDt && x.Date <= endDt, ct); + var hasOps = await _db.AgroOperations.AnyAsync(x => + x.TenantId == tenantId && + ((x.CompletedDate ?? x.PlannedDate) >= startDt && (x.CompletedDate ?? x.PlannedDate) <= endDt), ct); + + // Linked records only block tenant-admin delete; super-admin with force=true may delete anyway. + if ((hasCosts || hasSales || hasOps) && !(force && _currentUser.IsSuperAdmin)) + return Conflict(new { error = "Season has linked records (costs, sales or operations) within its date range." }); - var operationYears = await _db.AgroOperations - .Where(x => x.TenantId == tenantId && !x.IsDeleted) - .Select(x => (x.CompletedDate ?? x.PlannedDate).Year) - .ToListAsync(cancellationToken); - - var costYears = await _db.CostRecords - .Where(x => x.TenantId == tenantId && !x.IsDeleted) - .Select(x => x.Date.Year) - .ToListAsync(cancellationToken); - - var salesYears = await _db.Sales - .Where(x => x.TenantId == tenantId && !x.IsDeleted) - .Select(x => x.Date.Year) - .ToListAsync(cancellationToken); - - var years = operationYears - .Concat(costYears) - .Concat(salesYears) - .Distinct() - .OrderBy(y => y) - .ToList(); - - return Ok(years); + season.IsDeleted = true; + season.DeletedAtUtc = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + return NoContent(); } } diff --git a/src/AgroPlatform.Application/Common/Interfaces/IAppDbContext.cs b/src/AgroPlatform.Application/Common/Interfaces/IAppDbContext.cs index ba674eac..249d2bc4 100644 --- a/src/AgroPlatform.Application/Common/Interfaces/IAppDbContext.cs +++ b/src/AgroPlatform.Application/Common/Interfaces/IAppDbContext.cs @@ -12,6 +12,7 @@ using AgroPlatform.Domain.Machinery; using AgroPlatform.Domain.Notifications; using AgroPlatform.Domain.Sales; +using AgroPlatform.Domain.Seasons; using AgroPlatform.Domain.SuperAdmin; using AgroPlatform.Domain.Users; using AgroPlatform.Domain.Warehouses; @@ -76,6 +77,7 @@ public interface IAppDbContext DbSet RefreshTokens { get; } DbSet UserMfaSettings { get; } DbSet SuperAdminAuditLogs { get; } + DbSet Seasons { get; } // Approval workflow DbSet ApprovalRules { get; } diff --git a/src/AgroPlatform.Domain/Seasons/Season.cs b/src/AgroPlatform.Domain/Seasons/Season.cs new file mode 100644 index 00000000..25fa3b05 --- /dev/null +++ b/src/AgroPlatform.Domain/Seasons/Season.cs @@ -0,0 +1,23 @@ +using AgroPlatform.Domain.Common; + +namespace AgroPlatform.Domain.Seasons; + +/// +/// Tenant-scoped agricultural season with explicit start/end boundaries. +/// Replaces the legacy year-list model (derived from transaction timestamps). +/// +public class Season : AuditableEntity +{ + /// Short machine-friendly code, e.g. "2025/2026" or "2026". Unique per tenant. + public string Code { get; set; } = string.Empty; + + /// Human-facing name, e.g. "Сезон 2025/2026". + public string Name { get; set; } = string.Empty; + + public DateOnly StartDate { get; set; } + + public DateOnly EndDate { get; set; } + + /// True for the single season currently active for the tenant. + public bool IsCurrent { get; set; } +} diff --git a/src/AgroPlatform.Infrastructure/Persistence/AppDbContext.cs b/src/AgroPlatform.Infrastructure/Persistence/AppDbContext.cs index 2eae6b6f..065f0ba6 100644 --- a/src/AgroPlatform.Infrastructure/Persistence/AppDbContext.cs +++ b/src/AgroPlatform.Infrastructure/Persistence/AppDbContext.cs @@ -13,6 +13,7 @@ using AgroPlatform.Domain.Notifications; using AgroPlatform.Domain.Sales; using AgroPlatform.Domain.SuperAdmin; +using AgroPlatform.Domain.Seasons; using AgroPlatform.Domain.Users; using AgroPlatform.Domain.Warehouses; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; @@ -84,6 +85,7 @@ public AppDbContext(DbContextOptions options, ITenantService tenan public DbSet RefreshTokens => Set(); public DbSet UserMfaSettings => Set(); public DbSet SuperAdminAuditLogs => Set(); + public DbSet Seasons => Set(); // Approval workflow public DbSet ApprovalRules => Set(); diff --git a/src/AgroPlatform.Infrastructure/Persistence/Configurations/SeasonConfiguration.cs b/src/AgroPlatform.Infrastructure/Persistence/Configurations/SeasonConfiguration.cs new file mode 100644 index 00000000..55a764a3 --- /dev/null +++ b/src/AgroPlatform.Infrastructure/Persistence/Configurations/SeasonConfiguration.cs @@ -0,0 +1,36 @@ +using AgroPlatform.Domain.Seasons; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AgroPlatform.Infrastructure.Persistence.Configurations; + +public class SeasonConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(s => s.Id); + + builder.Property(s => s.Code).IsRequired().HasMaxLength(16); + builder.Property(s => s.Name).IsRequired().HasMaxLength(100); + builder.Property(s => s.StartDate).IsRequired(); + builder.Property(s => s.EndDate).IsRequired(); + builder.Property(s => s.IsCurrent).IsRequired(); + + builder.HasQueryFilter(s => !s.IsDeleted); + + builder.HasIndex(s => new { s.TenantId, s.Code }) + .IsUnique() + .HasFilter("\"IsDeleted\" = false"); + + // Only one current season per tenant (enforced at DB level). + builder.HasIndex(s => new { s.TenantId, s.IsCurrent }) + .IsUnique() + .HasFilter("\"IsCurrent\" = true AND \"IsDeleted\" = false") + .HasDatabaseName("IX_Seasons_TenantId_IsCurrent_Unique"); + + builder.ToTable("Seasons", t => + { + t.HasCheckConstraint("CK_Seasons_EndAfterStart", "\"EndDate\" > \"StartDate\""); + }); + } +} diff --git a/src/AgroPlatform.Infrastructure/Persistence/DataSeeder.cs b/src/AgroPlatform.Infrastructure/Persistence/DataSeeder.cs index a113009b..1f19c071 100644 --- a/src/AgroPlatform.Infrastructure/Persistence/DataSeeder.cs +++ b/src/AgroPlatform.Infrastructure/Persistence/DataSeeder.cs @@ -10,6 +10,7 @@ using AgroPlatform.Domain.Machinery; using AgroPlatform.Domain.Notifications; using AgroPlatform.Domain.Sales; +using AgroPlatform.Domain.Seasons; using AgroPlatform.Domain.Users; using AgroPlatform.Domain.Warehouses; using Microsoft.AspNetCore.Identity; @@ -92,6 +93,7 @@ public static async Task SeedAsync(IServiceProvider services, IConfiguration con await SeedItemCategoriesAsync(context, logger); await SeedSuperAdminAsync(scope.ServiceProvider, configuration, logger); await SeedDemoAsync(scope.ServiceProvider, context, logger); + await SeedDefaultSeasonsAsync(context, logger); // Optionally extend the demo tenant to investor-demo scale (80 fields, 25 machines, // 12 months of costs, 8 months of sales, 4 seasons of harvests). Gated on Demo:Scale. @@ -300,6 +302,66 @@ private static async Task SeedItemCategoriesAsync(AppDbContext context, ILogger } } + /// + /// Backfill three default seasons (2023/24, 2024/25, 2025/26) for every tenant + /// that has no seasons yet. Marks the most recent (2025/26) season as current. + /// Idempotent: skips tenants that already have at least one season. + /// + private static async Task SeedDefaultSeasonsAsync(AppDbContext context, ILogger logger) + { + try + { + var tenantIds = await context.Tenants.IgnoreQueryFilters() + .Select(t => t.Id) + .ToListAsync(); + + if (tenantIds.Count == 0) return; + + var tenantsWithSeasons = await context.Seasons.IgnoreQueryFilters() + .Select(s => s.TenantId) + .Distinct() + .ToListAsync(); + + var tenantsToSeed = tenantIds.Except(tenantsWithSeasons).ToList(); + if (tenantsToSeed.Count == 0) return; + + logger.LogInformation("Seeding default seasons for {Count} tenant(s)…", tenantsToSeed.Count); + + var defaults = new[] + { + (Code: "2023/2024", Name: "Сезон 2023/2024", Start: new DateOnly(2023, 8, 1), End: new DateOnly(2024, 7, 31), IsCurrent: false), + (Code: "2024/2025", Name: "Сезон 2024/2025", Start: new DateOnly(2024, 8, 1), End: new DateOnly(2025, 7, 31), IsCurrent: false), + (Code: "2025/2026", Name: "Сезон 2025/2026", Start: new DateOnly(2025, 8, 1), End: new DateOnly(2026, 7, 31), IsCurrent: true), + }; + + var now = DateTime.UtcNow; + foreach (var tenantId in tenantsToSeed) + { + foreach (var d in defaults) + { + context.Seasons.Add(new Season + { + TenantId = tenantId, + Code = d.Code, + Name = d.Name, + StartDate = d.Start, + EndDate = d.End, + IsCurrent = d.IsCurrent, + CreatedAtUtc = now, + }); + } + } + + await context.SaveChangesAsync(); + logger.LogInformation("Seeded {SeasonCount} default seasons across {TenantCount} tenant(s).", + tenantsToSeed.Count * defaults.Length, tenantsToSeed.Count); + } + catch (Exception ex) + { + logger.LogError(ex, "Error seeding default seasons."); + } + } + private static async Task SeedDemoAsync(IServiceProvider sp, AppDbContext context, ILogger logger) { try diff --git a/src/AgroPlatform.Infrastructure/Persistence/Migrations/20260424080242_AddSeasons.Designer.cs b/src/AgroPlatform.Infrastructure/Persistence/Migrations/20260424080242_AddSeasons.Designer.cs new file mode 100644 index 00000000..5de1ad30 --- /dev/null +++ b/src/AgroPlatform.Infrastructure/Persistence/Migrations/20260424080242_AddSeasons.Designer.cs @@ -0,0 +1,4672 @@ +// +using System; +using AgroPlatform.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AgroPlatform.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260424080242_AddSeasons")] + partial class AddSeasons + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.25") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AgroPlatform.Domain.AgroOperations.AgroOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AreaProcessed") + .HasPrecision(12, 4) + .HasColumnType("numeric(12,4)"); + + b.Property("CompletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("OperationType") + .IsRequired() + .HasColumnType("text"); + + b.Property("PerformedByEmployeeId") + .HasColumnType("uuid"); + + b.Property("PerformedByName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PlannedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FieldId", "PlannedDate"); + + b.HasIndex("TenantId", "FieldId"); + + b.HasIndex("TenantId", "Status"); + + b.ToTable("AgroOperations"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.AgroOperations.AgroOperationMachinery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgroOperationId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FuelUsed") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("HoursWorked") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MachineId") + .HasColumnType("uuid"); + + b.Property("OperatorName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AgroOperationId"); + + b.HasIndex("MachineId"); + + b.ToTable("AgroOperationMachineries"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.AgroOperations.AgroOperationResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActualQuantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("AgroOperationId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("PlannedQuantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("StockMoveId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UnitCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("WarehouseId") + .HasColumnType("uuid"); + + b.Property("WarehouseItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AgroOperationId"); + + b.HasIndex("WarehouseItemId"); + + b.ToTable("AgroOperationResources"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Approval.ApprovalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionType") + .HasColumnType("integer"); + + b.Property("Amount") + .HasColumnType("decimal(18,4)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DecidedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ApprovalRequests"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Approval.ApprovalRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionType") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("RequiredRole") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Threshold") + .HasColumnType("decimal(18,4)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ApprovalRules"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Authorization.RolePermission", b => + { + b.Property("RoleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PolicyName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsGranted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.HasKey("RoleName", "PolicyName"); + + b.ToTable("RolePermissions", (string)null); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Common.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("character varying(260)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "EntityType", "EntityId", "CreatedAtUtc") + .HasDatabaseName("IX_Attachments_Tenant_Entity_CreatedAtUtc"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Common.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AffectedColumns") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IpAddress") + .HasColumnType("text"); + + b.Property("NewValues") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OldValues") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_AuditEntries_TenantId"); + + b.HasIndex("TenantId", "CreatedAtUtc") + .HasDatabaseName("IX_AuditEntries_TenantId_CreatedAtUtc"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Economics.Budget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PlannedAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Year", "Category") + .IsUnique() + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Budgets"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Economics.CostRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgroOperationId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("SaleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AgroOperationId"); + + b.HasIndex("FieldId"); + + b.HasIndex("SaleId") + .IsUnique() + .HasFilter("\"SaleId\" IS NOT NULL"); + + b.HasIndex("TenantId", "Category"); + + b.HasIndex("TenantId", "Date"); + + b.HasIndex("TenantId", "FieldId"); + + b.ToTable("CostRecords"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.FeatureFlags.TenantFeatureFlag", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.HasKey("TenantId", "Key"); + + b.ToTable("TenantFeatureFlags", (string)null); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.CropRotationPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("PlannedCrop") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FieldId", "Year") + .IsUnique(); + + b.ToTable("CropRotationPlans"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.Field", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AreaHectares") + .HasPrecision(12, 4) + .HasColumnType("numeric(12,4)"); + + b.Property("CadastralArea") + .HasColumnType("numeric(18,4)"); + + b.Property("CadastralFetchedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CadastralNumber") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("CadastralOwnership") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CadastralPurpose") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CurrentCrop") + .HasColumnType("text"); + + b.Property("CurrentCropYear") + .HasColumnType("integer"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GeoJson") + .HasColumnType("text"); + + b.Property("Geometry") + .HasColumnType("geometry(Polygon, 4326)") + .HasColumnName("Geometry"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OwnershipType") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasDefaultValueSql("'\\x00'::bytea"); + + b.Property("SoilType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CadastralNumber"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("Fields"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.FieldCropHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Crop") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Year") + .HasColumnType("integer"); + + b.Property("YieldPerHectare") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("FieldId", "Year") + .IsUnique(); + + b.ToTable("FieldCropHistories"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.FieldFertilizer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CostPerKg") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FertilizerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RateKgPerHa") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TotalKg") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FieldId") + .HasDatabaseName("IX_FieldFertilizers_FieldId"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_FieldFertilizers_TenantId"); + + b.ToTable("FieldFertilizers"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.FieldHarvest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CropName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("GrainBatchId") + .HasColumnType("uuid"); + + b.Property("HarvestDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MoisturePercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PricePerTon") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("SyncedFromGrainStorage") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalRevenue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TotalTons") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Year") + .HasColumnType("integer"); + + b.Property("YieldTonsPerHa") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("FieldId") + .HasDatabaseName("IX_FieldHarvests_FieldId"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_FieldHarvests_TenantId"); + + b.HasIndex("FieldId", "Year") + .HasDatabaseName("IX_FieldHarvests_FieldId_Year"); + + b.ToTable("FieldHarvests"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.FieldInspection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("InspectorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PhotoUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Severity") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FieldId") + .HasDatabaseName("IX_FieldInspections_FieldId"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_FieldInspections_TenantId"); + + b.ToTable("FieldInspections"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.FieldProtection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CostPerLiter") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ProductName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProtectionType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RateLPerHa") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TotalLiters") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FieldId") + .HasDatabaseName("IX_FieldProtections_FieldId"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_FieldProtections_TenantId"); + + b.ToTable("FieldProtections"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.FieldSeeding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CropName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("SeedingDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SeedingRateKgPerHa") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalSeedKg") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Variety") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FieldId") + .HasDatabaseName("IX_FieldSeedings_FieldId"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_FieldSeedings_TenantId"); + + b.HasIndex("FieldId", "Year") + .HasDatabaseName("IX_FieldSeedings_FieldId_Year"); + + b.ToTable("FieldSeedings"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.FieldZone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("GeoJson") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("SoilType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FieldId") + .HasDatabaseName("IX_FieldZones_FieldId"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_FieldZones_TenantId"); + + b.ToTable("FieldZones"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.LandLease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnnualPayment") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ContractEndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ContractNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContractStartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("GrainPaymentTons") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OwnerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PaymentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Cash"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FieldId") + .HasDatabaseName("IX_LandLeases_FieldId"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_LandLeases_TenantId"); + + b.ToTable("LandLeases"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.LeasePayment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GrainBatchId") + .HasColumnType("uuid"); + + b.Property("GrainPricePerTon") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("GrainQuantityTons") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LandLeaseId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PaymentDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PaymentMethod") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("Cash"); + + b.Property("PaymentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Payment"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GrainBatchId"); + + b.HasIndex("LandLeaseId") + .HasDatabaseName("IX_LeasePayments_LandLeaseId"); + + b.ToTable("LeasePayments"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.SoilAnalysis", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("Humus") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Nitrogen") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Phosphorus") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)"); + + b.Property("Potassium") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)"); + + b.Property("SampleDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("ZoneId") + .HasColumnType("uuid"); + + b.Property("pH") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("FieldId") + .HasDatabaseName("IX_SoilAnalyses_FieldId"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_SoilAnalyses_TenantId"); + + b.ToTable("SoilAnalyses"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fuel.FuelNorm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MachineType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormLitersPerHa") + .HasPrecision(10, 3) + .HasColumnType("numeric(10,3)"); + + b.Property("NormLitersPerHour") + .HasPrecision(10, 3) + .HasColumnType("numeric(10,3)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OperationType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MachineType", "OperationType", "TenantId") + .IsUnique(); + + b.ToTable("FuelNorms", (string)null); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fuel.FuelTank", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapacityLiters") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CurrentLiters") + .ValueGeneratedOnAdd() + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasDefaultValue(0m); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FuelType") + .HasColumnType("integer"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PricePerLiter") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasDefaultValueSql("'\\x00'::bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("FuelTanks"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fuel.FuelTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DriverName") + .HasColumnType("text"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("FuelTankId") + .HasColumnType("uuid"); + + b.Property("InvoiceNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("MachineId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PricePerLiter") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QuantityLiters") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("SupplierName") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TransactionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FieldId"); + + b.HasIndex("FuelTankId"); + + b.HasIndex("MachineId"); + + b.ToTable("FuelTransactions"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.GrainStorage.GrainBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContractNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GlutenPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("GrainImpurityPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("GrainType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ImpurityPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("InitialQuantityTons") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MoisturePercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("NaturePerLiter") + .HasColumnType("integer"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OwnerName") + .HasColumnType("text"); + + b.Property("OwnershipType") + .HasColumnType("integer"); + + b.Property("PricePerTon") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ProteinPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("QualityClass") + .HasColumnType("integer"); + + b.Property("QuantityTons") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceivedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasDefaultValueSql("'\\x00'::bytea"); + + b.Property("SourceFieldId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SourceFieldId"); + + b.HasIndex("TenantId"); + + b.ToTable("GrainBatches"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.GrainStorage.GrainBatchPlacement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GrainBatchId") + .HasColumnType("uuid"); + + b.Property("GrainStorageId") + .HasColumnType("uuid"); + + b.Property("GrainStorageUnitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("QuantityTons") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GrainBatchId"); + + b.HasIndex("GrainStorageId"); + + b.HasIndex("TenantId"); + + b.ToTable("GrainBatchPlacements"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.GrainStorage.GrainMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BuyerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientOperationId") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GrainBatchId") + .HasColumnType("uuid"); + + b.Property("GrainTransferId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MovementDate") + .HasColumnType("timestamp with time zone"); + + b.Property("MovementType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("PricePerTon") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("QuantityTons") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SourceBatchId") + .HasColumnType("uuid"); + + b.Property("SourceStorageId") + .HasColumnType("uuid"); + + b.Property("TargetBatchId") + .HasColumnType("uuid"); + + b.Property("TargetStorageId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalRevenue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientOperationId") + .IsUnique() + .HasFilter("\"ClientOperationId\" IS NOT NULL"); + + b.HasIndex("GrainTransferId"); + + b.HasIndex("OperationId") + .HasFilter("\"OperationId\" IS NOT NULL"); + + b.HasIndex("SourceStorageId"); + + b.HasIndex("TargetStorageId"); + + b.HasIndex("GrainBatchId", "MovementDate"); + + b.ToTable("GrainMovements"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.GrainStorage.GrainStorage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapacityTons") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Location") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("StorageType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasFilter("\"Code\" IS NOT NULL AND \"IsDeleted\" = false"); + + b.ToTable("GrainStorages"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.GrainStorage.GrainTransfer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("QuantityTons") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SourceBatchId") + .HasColumnType("uuid"); + + b.Property("TargetBatchId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TransferDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SourceBatchId"); + + b.HasIndex("TargetBatchId"); + + b.HasIndex("TenantId"); + + b.ToTable("GrainTransfers"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.GrainStorage.GrainType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("GrainTypes"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.HR.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DateOfBirth") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HireDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HourlyRate") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PieceworkRate") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Position") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SalaryType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Hourly"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.HR.SalaryPayment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PaymentDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PaymentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Salary"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("TenantId"); + + b.ToTable("SalaryPayments"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.HR.WorkLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccruedAmount") + .ValueGeneratedOnAdd() + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasDefaultValue(0m); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("HoursWorked") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsPaid") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UnitsProduced") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("WorkDate") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkDescription") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("TenantId"); + + b.ToTable("WorkLogs"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Machinery.FuelLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FuelType") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MachineId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Quantity") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MachineId", "Date"); + + b.ToTable("FuelLogs"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Machinery.GpsTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FuelLevel") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Lat") + .HasColumnType("double precision"); + + b.Property("Lng") + .HasColumnType("double precision"); + + b.Property("Speed") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("VehicleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId", "Timestamp"); + + b.ToTable("GpsTracks", (string)null); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Machinery.Machine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedDriverId") + .HasColumnType("uuid"); + + b.Property("AssignedDriverName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Brand") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FuelConsumptionPerHour") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("FuelType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImeiNumber") + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.Property("InventoryNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastMaintenanceDate") + .HasColumnType("timestamp with time zone"); + + b.Property("MaintenanceIntervalHours") + .HasColumnType("numeric"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NextMaintenanceDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasDefaultValueSql("'\\x00'::bytea"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImeiNumber") + .IsUnique() + .HasFilter("\"ImeiNumber\" IS NOT NULL"); + + b.HasIndex("InventoryNumber", "TenantId") + .IsUnique(); + + b.ToTable("Machines"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Machinery.MachineWorkLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgroOperationId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HoursWorked") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MachineId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MachineId", "Date"); + + b.ToTable("MachineWorkLogs"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Machinery.MaintenanceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HoursAtMaintenance") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MachineId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MachineId"); + + b.ToTable("MaintenanceRecords"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Notifications.MobilePushToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "UserId", "Token") + .IsUnique(); + + b.ToTable("MobilePushTokens"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "IsRead"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Notifications.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("P256dhKey") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Endpoint") + .IsUnique(); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Sales.Sale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BuyerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("GrainMovementId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("PricePerUnit") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Product") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FieldId"); + + b.HasIndex("GrainMovementId") + .HasFilter("\"GrainMovementId\" IS NOT NULL"); + + b.HasIndex("TenantId", "Date"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Seasons.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId", "IsCurrent") + .IsUnique() + .HasDatabaseName("IX_Seasons_TenantId_IsCurrent_Unique") + .HasFilter("\"IsCurrent\" = true AND \"IsDeleted\" = false"); + + b.ToTable("Seasons", null, t => + { + t.HasCheckConstraint("CK_Seasons_EndAfterStart", "\"EndDate\" > \"StartDate\""); + }); + }); + + modelBuilder.Entity("AgroPlatform.Domain.SuperAdmin.SuperAdminAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AdminUserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("After") + .HasColumnType("jsonb"); + + b.Property("Before") + .HasColumnType("jsonb"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("AdminUserId"); + + b.HasIndex("OccurredAt"); + + b.ToTable("SuperAdminAuditLogs", (string)null); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Users.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RateLimitPerHour") + .HasColumnType("integer"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("WebhookEventTypes") + .HasColumnType("text"); + + b.Property("WebhookUrl") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Users.AppUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedByUserId") + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSuperAdmin") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RequirePasswordChange") + .HasColumnType("boolean"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Users.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompanyName") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Edrpou") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Inn") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Users.UserMfaSettings", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("BackupCodes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("[]"); + + b.Property("EnabledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("SecretKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("UserId"); + + b.ToTable("UserMfaSettings", (string)null); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.Batch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CostPerUnit") + .HasColumnType("numeric(18,4)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ReceivedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupplierName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ItemId", "ExpiryDate") + .HasDatabaseName("IX_Batches_ItemId_ExpiryDate"); + + b.ToTable("Batches"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.InventorySession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("WarehouseId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WarehouseId"); + + b.ToTable("InventorySessions", (string)null); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.InventorySessionLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActualQuantityBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("BaseUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("BatchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedQuantityBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("InventorySessionId") + .HasColumnType("uuid"); + + b.Property("IsCountRecorded") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InventorySessionId"); + + b.HasIndex("ItemId"); + + b.ToTable("InventorySessionLines", (string)null); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.ItemCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.ToTable("ItemCategories", (string)null); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.StockBalance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BalanceBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("BaseUnit") + .IsRequired() + .HasColumnType("text"); + + b.Property("BatchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("LastUpdatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("'\\x00'::bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("WarehouseId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BatchId"); + + b.HasIndex("ItemId"); + + b.HasIndex("WarehouseId", "ItemId", "BatchId", "TenantId") + .IsUnique() + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("StockBalances"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.StockLedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgroOperationId") + .HasColumnType("uuid"); + + b.Property("BalanceAfterBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("BaseUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("BatchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DocumentRef") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FieldId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("MoveType") + .HasColumnType("integer"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QuantityBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("StockMoveId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UnitCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OperationId") + .HasFilter("\"OperationId\" IS NOT NULL"); + + b.HasIndex("StockMoveId") + .HasFilter("\"StockMoveId\" IS NOT NULL"); + + b.HasIndex("WarehouseId", "ItemId", "CreatedAtUtc"); + + b.ToTable("StockLedgerEntries", (string)null); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.StockMove", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BatchId") + .HasColumnType("uuid"); + + b.Property("ClientOperationId") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("MoveType") + .HasColumnType("integer"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QuantityBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UnitCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("WarehouseId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BatchId"); + + b.HasIndex("ClientOperationId") + .IsUnique() + .HasFilter("\"ClientOperationId\" IS NOT NULL"); + + b.HasIndex("ItemId"); + + b.HasIndex("OperationId") + .HasFilter("\"OperationId\" IS NOT NULL"); + + b.HasIndex("WarehouseId", "ItemId"); + + b.ToTable("StockMoves"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.UnitConversionRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Factor") + .HasPrecision(22, 10) + .HasColumnType("numeric(22,10)"); + + b.Property("FromUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ToUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ToUnit"); + + b.HasIndex("FromUnit", "ToUnit") + .IsUnique(); + + b.ToTable("UnitConversionRules"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.UnitOfMeasure", b => + { + b.Property("Code") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Code"); + + b.ToTable("UnitsOfMeasure"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Location") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Warehouses"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.WarehouseItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MinimumQuantity") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PurchasePrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("WarehouseItems"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("AgroPlatform.Domain.AgroOperations.AgroOperation", b => + { + b.HasOne("AgroPlatform.Domain.Fields.Field", "Field") + .WithMany("Operations") + .HasForeignKey("FieldId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Field"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.AgroOperations.AgroOperationMachinery", b => + { + b.HasOne("AgroPlatform.Domain.AgroOperations.AgroOperation", "AgroOperation") + .WithMany("MachineryUsed") + .HasForeignKey("AgroOperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AgroPlatform.Domain.Machinery.Machine", "Machine") + .WithMany() + .HasForeignKey("MachineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AgroOperation"); + + b.Navigation("Machine"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.AgroOperations.AgroOperationResource", b => + { + b.HasOne("AgroPlatform.Domain.AgroOperations.AgroOperation", "AgroOperation") + .WithMany("Resources") + .HasForeignKey("AgroOperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AgroPlatform.Domain.Warehouses.WarehouseItem", "WarehouseItem") + .WithMany() + .HasForeignKey("WarehouseItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AgroOperation"); + + b.Navigation("WarehouseItem"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Economics.CostRecord", b => + { + b.HasOne("AgroPlatform.Domain.AgroOperations.AgroOperation", "AgroOperation") + .WithMany() + .HasForeignKey("AgroOperationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("AgroPlatform.Domain.Fields.Field", "Field") + .WithMany() + .HasForeignKey("FieldId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("AgroPlatform.Domain.Sales.Sale", "Sale") + .WithMany() + .HasForeignKey("SaleId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AgroOperation"); + + b.Navigation("Field"); + + b.Navigation("Sale"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.FeatureFlags.TenantFeatureFlag", b => + { + b.HasOne("AgroPlatform.Domain.Users.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.CropRotationPlan", b => + { + b.HasOne("AgroPlatform.Domain.Fields.Field", "Field") + .WithMany("RotationPlans") + .HasForeignKey("FieldId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Field"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.FieldCropHistory", b => + { + b.HasOne("AgroPlatform.Domain.Fields.Field", "Field") + .WithMany("CropHistory") + .HasForeignKey("FieldId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Field"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.FieldFertilizer", b => + { + b.HasOne("AgroPlatform.Domain.Fields.Field", "Field") + .WithMany("Fertilizers") + .HasForeignKey("FieldId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_FieldFertilizers_Fields"); + + b.Navigation("Field"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.FieldHarvest", b => + { + b.HasOne("AgroPlatform.Domain.Fields.Field", "Field") + .WithMany("Harvests") + .HasForeignKey("FieldId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_FieldHarvests_Fields"); + + b.Navigation("Field"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.FieldInspection", b => + { + b.HasOne("AgroPlatform.Domain.Fields.Field", "Field") + .WithMany("Inspections") + .HasForeignKey("FieldId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_FieldInspections_Fields"); + + b.Navigation("Field"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.FieldProtection", b => + { + b.HasOne("AgroPlatform.Domain.Fields.Field", "Field") + .WithMany("Protections") + .HasForeignKey("FieldId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_FieldProtections_Fields"); + + b.Navigation("Field"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.FieldSeeding", b => + { + b.HasOne("AgroPlatform.Domain.Fields.Field", "Field") + .WithMany("Seedings") + .HasForeignKey("FieldId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_FieldSeedings_Fields"); + + b.Navigation("Field"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.FieldZone", b => + { + b.HasOne("AgroPlatform.Domain.Fields.Field", "Field") + .WithMany("Zones") + .HasForeignKey("FieldId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_FieldZones_Fields"); + + b.Navigation("Field"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.LandLease", b => + { + b.HasOne("AgroPlatform.Domain.Fields.Field", "Field") + .WithMany("LandLeases") + .HasForeignKey("FieldId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_LandLeases_Fields"); + + b.Navigation("Field"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.LeasePayment", b => + { + b.HasOne("AgroPlatform.Domain.GrainStorage.GrainBatch", "GrainBatch") + .WithMany() + .HasForeignKey("GrainBatchId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_LeasePayments_GrainBatches"); + + b.HasOne("AgroPlatform.Domain.Fields.LandLease", "LandLease") + .WithMany("Payments") + .HasForeignKey("LandLeaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_LeasePayments_LandLeases"); + + b.Navigation("GrainBatch"); + + b.Navigation("LandLease"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.SoilAnalysis", b => + { + b.HasOne("AgroPlatform.Domain.Fields.Field", "Field") + .WithMany("SoilAnalyses") + .HasForeignKey("FieldId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_SoilAnalyses_Fields"); + + b.Navigation("Field"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fuel.FuelTransaction", b => + { + b.HasOne("AgroPlatform.Domain.Fuel.FuelTank", "FuelTank") + .WithMany("Transactions") + .HasForeignKey("FuelTankId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_FuelTransactions_FuelTanks"); + + b.Navigation("FuelTank"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.GrainStorage.GrainBatch", b => + { + b.HasOne("AgroPlatform.Domain.Fields.Field", "SourceField") + .WithMany() + .HasForeignKey("SourceFieldId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("SourceField"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.GrainStorage.GrainBatchPlacement", b => + { + b.HasOne("AgroPlatform.Domain.GrainStorage.GrainBatch", "GrainBatch") + .WithMany("Placements") + .HasForeignKey("GrainBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AgroPlatform.Domain.GrainStorage.GrainStorage", "GrainStorage") + .WithMany("Placements") + .HasForeignKey("GrainStorageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GrainBatch"); + + b.Navigation("GrainStorage"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.GrainStorage.GrainMovement", b => + { + b.HasOne("AgroPlatform.Domain.GrainStorage.GrainBatch", "GrainBatch") + .WithMany("Movements") + .HasForeignKey("GrainBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AgroPlatform.Domain.GrainStorage.GrainTransfer", "GrainTransfer") + .WithMany() + .HasForeignKey("GrainTransferId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("AgroPlatform.Domain.GrainStorage.GrainStorage", "SourceStorage") + .WithMany() + .HasForeignKey("SourceStorageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("AgroPlatform.Domain.GrainStorage.GrainStorage", "TargetStorage") + .WithMany() + .HasForeignKey("TargetStorageId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("GrainBatch"); + + b.Navigation("GrainTransfer"); + + b.Navigation("SourceStorage"); + + b.Navigation("TargetStorage"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.GrainStorage.GrainTransfer", b => + { + b.HasOne("AgroPlatform.Domain.GrainStorage.GrainBatch", "SourceBatch") + .WithMany() + .HasForeignKey("SourceBatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("AgroPlatform.Domain.GrainStorage.GrainBatch", "TargetBatch") + .WithMany() + .HasForeignKey("TargetBatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SourceBatch"); + + b.Navigation("TargetBatch"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.HR.SalaryPayment", b => + { + b.HasOne("AgroPlatform.Domain.HR.Employee", "Employee") + .WithMany("Payments") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_SalaryPayments_Employees"); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.HR.WorkLog", b => + { + b.HasOne("AgroPlatform.Domain.HR.Employee", "Employee") + .WithMany("WorkLogs") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_WorkLogs_Employees"); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Machinery.FuelLog", b => + { + b.HasOne("AgroPlatform.Domain.Machinery.Machine", "Machine") + .WithMany("FuelLogs") + .HasForeignKey("MachineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Machine"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Machinery.GpsTrack", b => + { + b.HasOne("AgroPlatform.Domain.Machinery.Machine", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Machinery.MachineWorkLog", b => + { + b.HasOne("AgroPlatform.Domain.Machinery.Machine", "Machine") + .WithMany("WorkLogs") + .HasForeignKey("MachineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Machine"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Machinery.MaintenanceRecord", b => + { + b.HasOne("AgroPlatform.Domain.Machinery.Machine", "Machine") + .WithMany("MaintenanceRecords") + .HasForeignKey("MachineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Machine"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Sales.Sale", b => + { + b.HasOne("AgroPlatform.Domain.Fields.Field", "Field") + .WithMany() + .HasForeignKey("FieldId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("AgroPlatform.Domain.GrainStorage.GrainMovement", "GrainMovement") + .WithMany() + .HasForeignKey("GrainMovementId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Field"); + + b.Navigation("GrainMovement"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Users.UserMfaSettings", b => + { + b.HasOne("AgroPlatform.Domain.Users.AppUser", null) + .WithOne() + .HasForeignKey("AgroPlatform.Domain.Users.UserMfaSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.Batch", b => + { + b.HasOne("AgroPlatform.Domain.Warehouses.WarehouseItem", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.InventorySession", b => + { + b.HasOne("AgroPlatform.Domain.Warehouses.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.InventorySessionLine", b => + { + b.HasOne("AgroPlatform.Domain.Warehouses.InventorySession", "Session") + .WithMany("Lines") + .HasForeignKey("InventorySessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AgroPlatform.Domain.Warehouses.WarehouseItem", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.ItemCategory", b => + { + b.HasOne("AgroPlatform.Domain.Warehouses.ItemCategory", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.StockBalance", b => + { + b.HasOne("AgroPlatform.Domain.Warehouses.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("AgroPlatform.Domain.Warehouses.WarehouseItem", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("AgroPlatform.Domain.Warehouses.Warehouse", "Warehouse") + .WithMany("Balances") + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.StockMove", b => + { + b.HasOne("AgroPlatform.Domain.Warehouses.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("AgroPlatform.Domain.Warehouses.WarehouseItem", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("AgroPlatform.Domain.Warehouses.Warehouse", "Warehouse") + .WithMany("StockMoves") + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.UnitConversionRule", b => + { + b.HasOne("AgroPlatform.Domain.Warehouses.UnitOfMeasure", "From") + .WithMany("FromRules") + .HasForeignKey("FromUnit") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("AgroPlatform.Domain.Warehouses.UnitOfMeasure", "To") + .WithMany("ToRules") + .HasForeignKey("ToUnit") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("From"); + + b.Navigation("To"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.WarehouseItem", b => + { + b.HasOne("AgroPlatform.Domain.Warehouses.ItemCategory", "ItemCategory") + .WithMany("Items") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ItemCategory"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("AgroPlatform.Domain.Users.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("AgroPlatform.Domain.Users.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AgroPlatform.Domain.Users.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("AgroPlatform.Domain.Users.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AgroPlatform.Domain.AgroOperations.AgroOperation", b => + { + b.Navigation("MachineryUsed"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.Field", b => + { + b.Navigation("CropHistory"); + + b.Navigation("Fertilizers"); + + b.Navigation("Harvests"); + + b.Navigation("Inspections"); + + b.Navigation("LandLeases"); + + b.Navigation("Operations"); + + b.Navigation("Protections"); + + b.Navigation("RotationPlans"); + + b.Navigation("Seedings"); + + b.Navigation("SoilAnalyses"); + + b.Navigation("Zones"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fields.LandLease", b => + { + b.Navigation("Payments"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Fuel.FuelTank", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.GrainStorage.GrainBatch", b => + { + b.Navigation("Movements"); + + b.Navigation("Placements"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.GrainStorage.GrainStorage", b => + { + b.Navigation("Placements"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.HR.Employee", b => + { + b.Navigation("Payments"); + + b.Navigation("WorkLogs"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Machinery.Machine", b => + { + b.Navigation("FuelLogs"); + + b.Navigation("MaintenanceRecords"); + + b.Navigation("WorkLogs"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.InventorySession", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.ItemCategory", b => + { + b.Navigation("Children"); + + b.Navigation("Items"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.UnitOfMeasure", b => + { + b.Navigation("FromRules"); + + b.Navigation("ToRules"); + }); + + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.Warehouse", b => + { + b.Navigation("Balances"); + + b.Navigation("StockMoves"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AgroPlatform.Infrastructure/Persistence/Migrations/20260424080242_AddSeasons.cs b/src/AgroPlatform.Infrastructure/Persistence/Migrations/20260424080242_AddSeasons.cs new file mode 100644 index 00000000..6fa78838 --- /dev/null +++ b/src/AgroPlatform.Infrastructure/Persistence/Migrations/20260424080242_AddSeasons.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AgroPlatform.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddSeasons : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Seasons", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Code = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + StartDate = table.Column(type: "date", nullable: false), + EndDate = table.Column(type: "date", nullable: false), + IsCurrent = table.Column(type: "boolean", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "text", nullable: true), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedBy = table.Column(type: "text", nullable: true), + IsDeleted = table.Column(type: "boolean", nullable: false), + DeletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Seasons", x => x.Id); + table.CheckConstraint("CK_Seasons_EndAfterStart", "\"EndDate\" > \"StartDate\""); + }); + + migrationBuilder.CreateIndex( + name: "IX_Seasons_TenantId_Code", + table: "Seasons", + columns: new[] { "TenantId", "Code" }, + unique: true, + filter: "\"IsDeleted\" = false"); + + migrationBuilder.CreateIndex( + name: "IX_Seasons_TenantId_IsCurrent_Unique", + table: "Seasons", + columns: new[] { "TenantId", "IsCurrent" }, + unique: true, + filter: "\"IsCurrent\" = true AND \"IsDeleted\" = false"); + + // Idempotent seed: give every existing tenant three default seasons + // (2023/2024, 2024/2025, 2025/2026), running Aug 1 → Jul 31. + // Only the 2025/2026 season is flagged as current. The NOT EXISTS guard + // keeps the migration safe to replay against databases where seasons + // have already been created (e.g. re-running on a developer machine). + migrationBuilder.Sql(@" +DO $$ +DECLARE + t RECORD; + now_utc timestamptz := NOW() AT TIME ZONE 'UTC'; +BEGIN + FOR t IN SELECT ""Id"" FROM ""Tenants"" WHERE ""IsActive"" = true LOOP + IF NOT EXISTS (SELECT 1 FROM ""Seasons"" WHERE ""TenantId"" = t.""Id"") THEN + INSERT INTO ""Seasons"" + (""Id"", ""Code"", ""Name"", ""StartDate"", ""EndDate"", ""IsCurrent"", ""TenantId"", ""CreatedAtUtc"", ""IsDeleted"") + VALUES + (gen_random_uuid(), '2023/2024', 'Сезон 2023/2024', DATE '2023-08-01', DATE '2024-07-31', false, t.""Id"", now_utc, false), + (gen_random_uuid(), '2024/2025', 'Сезон 2024/2025', DATE '2024-08-01', DATE '2025-07-31', false, t.""Id"", now_utc, false), + (gen_random_uuid(), '2025/2026', 'Сезон 2025/2026', DATE '2025-08-01', DATE '2026-07-31', true, t.""Id"", now_utc, false); + END IF; + END LOOP; +END $$; +"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Seasons"); + } + } +} diff --git a/src/AgroPlatform.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/AgroPlatform.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 39833c07..03697063 100644 --- a/src/AgroPlatform.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/AgroPlatform.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -2852,6 +2852,69 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Sales"); }); + modelBuilder.Entity("AgroPlatform.Domain.Seasons.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId", "IsCurrent") + .IsUnique() + .HasDatabaseName("IX_Seasons_TenantId_IsCurrent_Unique") + .HasFilter("\"IsCurrent\" = true AND \"IsDeleted\" = false"); + + b.ToTable("Seasons", null, t => + { + t.HasCheckConstraint("CK_Seasons_EndAfterStart", "\"EndDate\" > \"StartDate\""); + }); + }); + modelBuilder.Entity("AgroPlatform.Domain.SuperAdmin.SuperAdminAuditLog", b => { b.Property("Id") diff --git a/tests/AgroPlatform.IntegrationTests/Seasons/SeasonsTests.cs b/tests/AgroPlatform.IntegrationTests/Seasons/SeasonsTests.cs new file mode 100644 index 00000000..2a30ba23 --- /dev/null +++ b/tests/AgroPlatform.IntegrationTests/Seasons/SeasonsTests.cs @@ -0,0 +1,274 @@ +using System.Net; +using System.Net.Http.Json; +using AgroPlatform.Domain.Economics; +using AgroPlatform.Domain.Enums; +using AgroPlatform.Domain.Seasons; +using AgroPlatform.Domain.Users; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace AgroPlatform.IntegrationTests.Seasons; + +[Collection("Integration Tests")] +public sealed class SeasonsTests : IntegrationTestBase +{ + public SeasonsTests(CustomWebApplicationFactory factory) : base(factory) + { + } + + private sealed record SeasonDto(Guid Id, string Code, string Name, DateOnly StartDate, DateOnly EndDate, bool IsCurrent); + + private HttpClient CreateAdminClient(string role = "CompanyAdmin", Guid? tenantId = null) + { + var client = Factory.CreateClient(); + var tid = tenantId ?? TenantId; + client.DefaultRequestHeaders.Add("X-Tenant-Id", tid.ToString()); + client.DefaultRequestHeaders.Add("X-Test-Tenant-Id", tid.ToString()); + client.DefaultRequestHeaders.Add("X-Test-Role", role); + return client; + } + + private HttpClient CreateSuperAdminClient() + { + // The [SuperAdminRequired] filter also requires a UserMfaSettings row with + // IsEnabled=true for the authenticated user id. Seed it once per test run. + EnsureTestUserMfaEnabledAsync().GetAwaiter().GetResult(); + + var client = Factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Tenant-Id", TenantId.ToString()); + client.DefaultRequestHeaders.Add("X-Test-Role", "SuperAdmin"); + client.DefaultRequestHeaders.Add("X-Test-IsSuperAdmin", "true"); + client.DefaultRequestHeaders.Add("X-Test-MfaVerified", "true"); + return client; + } + + private async Task EnsureTestUserMfaEnabledAsync() + { + using var scope = CreateScope(); + var db = GetDbContext(scope); + var userId = TestAuthHandler.TestUserId.ToString(); + + // FK requires a real AspNetUsers row before we can add UserMfaSettings. + var userExists = await db.Users.IgnoreQueryFilters().AnyAsync(u => u.Id == userId); + if (!userExists) + { + db.Users.Add(new AppUser + { + Id = userId, + UserName = "test-super@example.com", + NormalizedUserName = "TEST-SUPER@EXAMPLE.COM", + Email = "test-super@example.com", + NormalizedEmail = "TEST-SUPER@EXAMPLE.COM", + EmailConfirmed = true, + SecurityStamp = Guid.NewGuid().ToString(), + ConcurrencyStamp = Guid.NewGuid().ToString(), + FirstName = "Test", + LastName = "SuperAdmin", + Role = UserRole.SuperAdmin, + TenantId = Guid.Empty, + IsActive = true, + IsSuperAdmin = true, + }); + await db.SaveChangesAsync(); + } + + var existing = await db.UserMfaSettings.FirstOrDefaultAsync(x => x.UserId == userId); + if (existing is not null) + { + if (!existing.IsEnabled) + { + existing.IsEnabled = true; + await db.SaveChangesAsync(); + } + return; + } + db.UserMfaSettings.Add(new AgroPlatform.Domain.Users.UserMfaSettings + { + UserId = userId, + SecretKey = "JBSWY3DPEHPK3PXP", // any valid base32; not exercised by SuperAdminRequiredFilter. + IsEnabled = true, + BackupCodes = "[]", + EnabledAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + } + + private async Task SeedSeasonAsync(Guid tenantId, string code, DateOnly start, DateOnly end, bool isCurrent) + { + using var scope = CreateScope(); + var db = GetDbContext(scope); + // If setting current, clear any existing current first to satisfy partial unique index. + // IgnoreQueryFilters because test scopes have no HTTP context → DbContext's + // global tenant filter would otherwise hide every season row from this read. + if (isCurrent) + { + var curr = await db.Seasons.IgnoreQueryFilters() + .Where(s => s.TenantId == tenantId && s.IsCurrent && !s.IsDeleted).ToListAsync(); + foreach (var c in curr) c.IsCurrent = false; + if (curr.Count > 0) await db.SaveChangesAsync(); + } + var season = new Season + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Code = code, + Name = $"Сезон {code}", + StartDate = start, + EndDate = end, + IsCurrent = isCurrent, + }; + db.Seasons.Add(season); + await db.SaveChangesAsync(); + return season.Id; + } + + // 1. Tenant-scoped GET returns only own tenant's seasons. + [Fact] + public async Task List_ReturnsOnlySeasonsOfCurrentTenant() + { + var otherTenantId = Guid.NewGuid(); + using (var scope = CreateScope()) + { + var db = GetDbContext(scope); + db.Tenants.Add(new Tenant { Id = otherTenantId, Name = "Other Tenant", IsActive = true }); + await db.SaveChangesAsync(); + } + + var mine = await SeedSeasonAsync(TenantId, $"T-{Guid.NewGuid():N}".Substring(0, 8), new DateOnly(2024, 8, 1), new DateOnly(2025, 7, 31), false); + var other = await SeedSeasonAsync(otherTenantId, $"O-{Guid.NewGuid():N}".Substring(0, 8), new DateOnly(2024, 8, 1), new DateOnly(2025, 7, 31), false); + + using var client = CreateAdminClient(); + var items = await client.GetFromJsonAsync>("/api/seasons", JsonOptions); + items.Should().NotBeNull(); + items!.Should().Contain(s => s.Id == mine); + items.Select(s => s.Id).Should().NotContain(other); + } + + // 2. SetCurrent flips exactly one IsCurrent=true per tenant. + [Fact] + public async Task SetCurrent_FlipsExactlyOneCurrentSeason() + { + var codeA = $"A-{Guid.NewGuid():N}".Substring(0, 8); + var codeB = $"B-{Guid.NewGuid():N}".Substring(0, 8); + var a = await SeedSeasonAsync(TenantId, codeA, new DateOnly(2022, 8, 1), new DateOnly(2023, 7, 31), true); + var b = await SeedSeasonAsync(TenantId, codeB, new DateOnly(2023, 8, 1), new DateOnly(2024, 7, 31), false); + + using var client = CreateAdminClient(); + var resp = await client.PostAsync($"/api/seasons/{b}/set-current", content: null); + resp.StatusCode.Should().Be(HttpStatusCode.NoContent); + + using var scope = CreateScope(); + var db = GetDbContext(scope); + var currents = await db.Seasons.IgnoreQueryFilters() + .Where(s => s.TenantId == TenantId && s.IsCurrent && !s.IsDeleted).ToListAsync(); + currents.Should().ContainSingle().Which.Id.Should().Be(b); + + var aReloaded = await db.Seasons.IgnoreQueryFilters().FirstAsync(s => s.Id == a); + aReloaded.IsCurrent.Should().BeFalse(); + } + + // 3. Delete blocked when linked CostRecord falls in season range (tenant admin). + [Fact] + public async Task Delete_WithLinkedCostRecord_Returns409ForTenantAdmin() + { + var code = $"D-{Guid.NewGuid():N}".Substring(0, 8); + var seasonId = await SeedSeasonAsync(TenantId, code, new DateOnly(2020, 8, 1), new DateOnly(2021, 7, 31), false); + + using (var scope = CreateScope()) + { + var db = GetDbContext(scope); + db.CostRecords.Add(new CostRecord + { + Id = Guid.NewGuid(), + TenantId = TenantId, + Category = CostCategory.Other, + Amount = 100m, + Currency = "UAH", + Date = new DateTime(2021, 1, 15, 0, 0, 0, DateTimeKind.Utc), + Description = "linked cost record", + }); + await db.SaveChangesAsync(); + } + + using var client = CreateAdminClient(); + var resp = await client.DeleteAsync($"/api/seasons/{seasonId}"); + resp.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + // 4. Super-admin can force-delete even with linked records. + [Fact] + public async Task Delete_ForceByPlatformSuperAdmin_DeletesDespiteLinkedCostRecord() + { + var code = $"F-{Guid.NewGuid():N}".Substring(0, 8); + var seasonId = await SeedSeasonAsync(TenantId, code, new DateOnly(2019, 8, 1), new DateOnly(2020, 7, 31), false); + + using (var scope = CreateScope()) + { + var db = GetDbContext(scope); + db.CostRecords.Add(new CostRecord + { + Id = Guid.NewGuid(), + TenantId = TenantId, + Category = CostCategory.Other, + Amount = 50m, + Currency = "UAH", + Date = new DateTime(2019, 12, 1, 0, 0, 0, DateTimeKind.Utc), + Description = "linked cost record 2", + }); + await db.SaveChangesAsync(); + } + + using var client = CreateSuperAdminClient(); + var resp = await client.DeleteAsync($"/api/seasons/{seasonId}?force=true"); + resp.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + // 5. Non-admin (Manager) cannot create → 403. + [Fact] + public async Task Create_AsNonAdmin_Returns403() + { + using var client = CreateAdminClient(role: "Manager"); + var resp = await client.PostAsJsonAsync("/api/seasons", new + { + code = $"N-{Guid.NewGuid():N}".Substring(0, 8), + name = "Manager-attempt", + startDate = "2030-08-01", + endDate = "2031-07-31", + isCurrent = false, + }, JsonOptions); + + resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + // 6. Super-admin creating a season through /api/admin/... writes exactly one audit log entry. + [Fact] + public async Task SuperAdmin_Create_WritesExactlyOneAuditLogEntry() + { + int before; + using (var scope = CreateScope()) + { + before = await GetDbContext(scope).SuperAdminAuditLogs.CountAsync(); + } + + using var client = CreateSuperAdminClient(); + var resp = await client.PostAsJsonAsync($"/api/admin/tenants/{TenantId}/seasons", new + { + code = $"S-{Guid.NewGuid():N}".Substring(0, 8), + name = "SuperAdmin-created", + startDate = "2028-08-01", + endDate = "2029-07-31", + isCurrent = false, + }, JsonOptions); + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + using var scope2 = CreateScope(); + var db = GetDbContext(scope2); + var after = await db.SuperAdminAuditLogs.CountAsync(); + (after - before).Should().Be(1); + + var entry = await db.SuperAdminAuditLogs.OrderByDescending(x => x.OccurredAt).FirstAsync(); + entry.Action.Should().Be("season.create"); + entry.TargetType.Should().Be(nameof(Season)); + } +} diff --git a/tests/AgroPlatform.UnitTests/TestDbContext.cs b/tests/AgroPlatform.UnitTests/TestDbContext.cs index 95ea9157..d7b1d0ca 100644 --- a/tests/AgroPlatform.UnitTests/TestDbContext.cs +++ b/tests/AgroPlatform.UnitTests/TestDbContext.cs @@ -12,6 +12,7 @@ using AgroPlatform.Domain.Machinery; using AgroPlatform.Domain.Notifications; using AgroPlatform.Domain.Sales; +using AgroPlatform.Domain.Seasons; using AgroPlatform.Domain.SuperAdmin; using AgroPlatform.Domain.Users; using AgroPlatform.Domain.Warehouses; @@ -59,6 +60,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.HasKey(x => x.Id); e.Property(x => x.Action).HasMaxLength(100); }); + + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.Code).HasMaxLength(16); + e.Property(x => x.Name).HasMaxLength(100); + }); } public DbSet Warehouses => Set(); @@ -114,6 +122,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet RefreshTokens => Set(); public DbSet UserMfaSettings => Set(); public DbSet SuperAdminAuditLogs => Set(); + public DbSet Seasons => Set(); public DbSet StockLedgerEntries => Set(); public DbSet ItemCategories => Set(); public DbSet InventorySessions => Set();