diff --git a/frontend/src/api/currency.ts b/frontend/src/api/currency.ts new file mode 100644 index 00000000..13f72587 --- /dev/null +++ b/frontend/src/api/currency.ts @@ -0,0 +1,23 @@ +import apiClient from './axios'; +import type { SupportedCurrency } from './me'; + +export interface ExchangeRateDto { + code: 'USD' | 'EUR'; + date: string; // ISO date + rateToUah: number; +} + +export interface CurrencyPreferences { + preferredCurrency: SupportedCurrency; +} + +export const getLatestRates = () => + apiClient.get('/api/currency/rates/latest').then((r) => r.data); + +export const getPreferences = () => + apiClient.get('/api/currency/preferences').then((r) => r.data); + +export const updatePreferences = (preferredCurrency: SupportedCurrency) => + apiClient + .put('/api/currency/preferences', { preferredCurrency }) + .then((r) => r.data); diff --git a/frontend/src/api/me.ts b/frontend/src/api/me.ts index 2172c082..a6c50580 100644 --- a/frontend/src/api/me.ts +++ b/frontend/src/api/me.ts @@ -11,7 +11,10 @@ export interface MeResponse { isSuperAdmin: boolean; mfaEnabled: boolean; mfaRequired: boolean; + preferredCurrency: SupportedCurrency; } +export type SupportedCurrency = 'UAH' | 'USD' | 'EUR'; + export const getMe = () => apiClient.get('/api/me').then((r) => r.data); \ No newline at end of file diff --git a/frontend/src/hooks/useFormatCurrency.ts b/frontend/src/hooks/useFormatCurrency.ts new file mode 100644 index 00000000..8befb0b2 --- /dev/null +++ b/frontend/src/hooks/useFormatCurrency.ts @@ -0,0 +1,57 @@ +import { useCallback } from 'react'; +import { useCurrencyStore } from '../stores/currencyStore'; +import type { SupportedCurrency } from '../api/me'; + +export interface FormatCurrencyOptions { + /** Override target currency; defaults to user's preferred. */ + target?: SupportedCurrency; + /** Fraction digits for the output amount; defaults 2. */ + fractionDigits?: number; +} + +/** + * Returns a memoised formatter that converts a UAH amount into the user's + * preferred display currency using the last-known NBU rate. + * + * Invariants per ROADMAP "Decisions locked / Currency": + * - All stored amounts in the DB are UAH. + * - Conversion happens at presentation only. + * - Fallback: if the target currency's rate is not loaded yet (or failed), + * we return the UAH value labelled as UAH (graceful degrade). + */ +export function useFormatCurrency() { + const preferredCurrency = useCurrencyStore((s) => s.preferredCurrency); + const rates = useCurrencyStore((s) => s.rates); + + return useCallback( + (amountUah: number | null | undefined, opts?: FormatCurrencyOptions): string => { + const value = typeof amountUah === 'number' && Number.isFinite(amountUah) ? amountUah : 0; + const target: SupportedCurrency = opts?.target ?? preferredCurrency; + const fractionDigits = opts?.fractionDigits ?? 2; + + let displayValue = value; + let displayCode: SupportedCurrency = target; + + if (target === 'UAH') { + displayValue = value; + } else { + const rate = rates[target]; + if (rate && rate > 0) { + displayValue = value / rate; + } else { + // Rate unknown → degrade to UAH. Keeps product usable offline / before load. + displayCode = 'UAH'; + displayValue = value; + } + } + + return new Intl.NumberFormat('uk-UA', { + style: 'currency', + currency: displayCode, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }).format(displayValue); + }, + [preferredCurrency, rates] + ); +} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 68d8c3c2..5afb9e16 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -1096,6 +1096,12 @@ const en: Translations = { language: 'Language', langUk: 'Українська', langEn: 'English', + currency: 'Currency', + currencyHint: 'Display currency. Data is stored in UAH; converted at NBU rate.', + currencyUah: 'Hryvnia (UAH)', + currencyUsd: 'US Dollar (USD)', + currencyEur: 'Euro (EUR)', + currencySaveError: 'Failed to save currency', }, budget: { title: 'Budget Planning', diff --git a/frontend/src/i18n/uk.ts b/frontend/src/i18n/uk.ts index f0ad4ef8..7271d77d 100644 --- a/frontend/src/i18n/uk.ts +++ b/frontend/src/i18n/uk.ts @@ -1093,6 +1093,12 @@ const uk = { language: 'Мова', langUk: 'Українська', langEn: 'English', + currency: 'Валюта', + currencyHint: 'Валюта відображення. Дані в базі зберігаються в UAH; конвертація за курсом НБУ.', + currencyUah: 'Гривня (UAH)', + currencyUsd: 'Долар США (USD)', + currencyEur: 'Євро (EUR)', + currencySaveError: 'Не вдалося зберегти валюту', }, budget: { title: 'Планування бюджету', diff --git a/frontend/src/pages/Profile/ProfilePage.tsx b/frontend/src/pages/Profile/ProfilePage.tsx index 067cba77..e4638fb6 100644 --- a/frontend/src/pages/Profile/ProfilePage.tsx +++ b/frontend/src/pages/Profile/ProfilePage.tsx @@ -1,14 +1,32 @@ -import { Card, Descriptions, Button, Space, Tag } from 'antd'; +import { Card, Descriptions, Button, Space, Tag, Select, message } from 'antd'; import { GlobalOutlined } from '@ant-design/icons'; +import { useEffect } from 'react'; import PageHeader from '../../components/PageHeader'; import { useTranslation } from '../../i18n'; import { useRole } from '../../hooks/useRole'; import { useAuthStore } from '../../stores/authStore'; +import { useCurrencyStore } from '../../stores/currencyStore'; +import type { SupportedCurrency } from '../../api/me'; export default function ProfilePage() { const { t, lang, setLang } = useTranslation(); const { isAdmin, isManager, isWarehouseOperator, isAccountant } = useRole(); const { token, email: storedEmail } = useAuthStore(); + const preferredCurrency = useCurrencyStore((s) => s.preferredCurrency); + const loadCurrency = useCurrencyStore((s) => s.load); + const setPreferredCurrency = useCurrencyStore((s) => s.setPreferredCurrency); + + useEffect(() => { + void loadCurrency(); + }, [loadCurrency]); + + const handleCurrencyChange = async (c: SupportedCurrency) => { + try { + await setPreferredCurrency(c); + } catch { + message.error(t.profile.currencySaveError); + } + }; // Derive current role string from the hook flags const roleKey = isAdmin @@ -92,6 +110,21 @@ export default function ProfilePage() { + + + + value={preferredCurrency} + onChange={handleCurrencyChange} + style={{ width: 240 }} + options={[ + { value: 'UAH', label: t.profile.currencyUah }, + { value: 'USD', label: t.profile.currencyUsd }, + { value: 'EUR', label: t.profile.currencyEur }, + ]} + /> + {t.profile.currencyHint} + + diff --git a/frontend/src/stores/currencyStore.ts b/frontend/src/stores/currencyStore.ts new file mode 100644 index 00000000..fddfafcd --- /dev/null +++ b/frontend/src/stores/currencyStore.ts @@ -0,0 +1,67 @@ +import { create } from 'zustand'; +import { getLatestRates, getPreferences, updatePreferences, type ExchangeRateDto } from '../api/currency'; +import type { SupportedCurrency } from '../api/me'; +import { useAuthStore } from './authStore'; + +interface CurrencyState { + preferredCurrency: SupportedCurrency; + rates: Record<'USD' | 'EUR', number | null>; + loaded: boolean; + loading: boolean; + loadedForTenantId: string | null; + + load: () => Promise; + setPreferredCurrency: (c: SupportedCurrency) => Promise; + reset: () => void; +} + +const emptyRates = (): Record<'USD' | 'EUR', number | null> => ({ USD: null, EUR: null }); + +export const useCurrencyStore = create((set, get) => ({ + preferredCurrency: 'UAH', + rates: emptyRates(), + loaded: false, + loading: false, + loadedForTenantId: null, + + load: async () => { + const { token, tenantId } = useAuthStore.getState(); + if (!token || !tenantId) { + set({ preferredCurrency: 'UAH', rates: emptyRates(), loaded: false, loading: false, loadedForTenantId: null }); + return; + } + if (get().loading) return; + if (get().loaded && get().loadedForTenantId === tenantId) return; + + set({ loading: true }); + try { + const [prefs, rates] = await Promise.all([getPreferences(), getLatestRates()]); + const next = emptyRates(); + for (const r of rates as ExchangeRateDto[]) { + if (r.code === 'USD' || r.code === 'EUR') next[r.code] = r.rateToUah; + } + set({ + preferredCurrency: prefs.preferredCurrency, + rates: next, + loaded: true, + loading: false, + loadedForTenantId: tenantId, + }); + } catch { + set({ loading: false }); + } + }, + + setPreferredCurrency: async (c) => { + const prev = get().preferredCurrency; + set({ preferredCurrency: c }); + try { + await updatePreferences(c); + } catch (e) { + set({ preferredCurrency: prev }); + throw e; + } + }, + + reset: () => set({ preferredCurrency: 'UAH', rates: emptyRates(), loaded: false, loading: false, loadedForTenantId: null }), +})); diff --git a/src/AgroPlatform.Api/Controllers/CurrencyController.cs b/src/AgroPlatform.Api/Controllers/CurrencyController.cs new file mode 100644 index 00000000..35a41bf5 --- /dev/null +++ b/src/AgroPlatform.Api/Controllers/CurrencyController.cs @@ -0,0 +1,109 @@ +using System.Security.Claims; +using AgroPlatform.Application.Common.Interfaces; +using AgroPlatform.Domain.Users; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace AgroPlatform.Api.Controllers; + +/// +/// Currency preferences and exchange rates. Base currency in DB is always UAH; +/// this controller exposes stored NBU rates and the signed-in user's display preference. +/// See ROADMAP.md "Decisions locked / Currency". +/// +[ApiController] +[Authorize] +[Route("api/currency")] +[Produces("application/json")] +public sealed class CurrencyController : ControllerBase +{ + private static readonly string[] AllowedCodes = { "UAH", "USD", "EUR" }; + private static readonly string[] TrackedCodes = { "USD", "EUR" }; + + private readonly IAppDbContext _db; + private readonly INbuCurrencyService _nbu; + + public CurrencyController(IAppDbContext db, INbuCurrencyService nbu) + { + _db = db; + _nbu = nbu; + } + + public record RateDto(string Code, DateOnly Date, decimal RateToUah); + public record PreferencesDto(string PreferredCurrency); + public record UpdatePreferencesRequest(string PreferredCurrency); + + /// Latest stored rates for tracked currencies (USD, EUR). + [HttpGet("rates/latest")] + public async Task GetLatestRates(CancellationToken ct) + { + var rows = new List(); + foreach (var code in TrackedCodes) + { + var r = await _db.ExchangeRates + .Where(x => x.Code == code) + .OrderByDescending(x => x.Date) + .Select(x => new RateDto(x.Code, x.Date, x.RateToUah)) + .FirstOrDefaultAsync(ct); + if (r is not null) rows.Add(r); + } + return Ok(rows); + } + + /// Rate for on (fallback to previous business day). + [HttpGet("rates")] + public async Task GetRate([FromQuery] string code, [FromQuery] DateOnly date, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(code)) return BadRequest(new { error = "code required" }); + code = code.ToUpperInvariant(); + if (code == "UAH") return Ok(new RateDto("UAH", date, 1m)); + var rate = await _nbu.GetRateAsync(code, date, ct); + if (rate is null) return NotFound(new { error = "no rate for given currency" }); + // Look up the actual row date (could be earlier than requested). + var row = await _db.ExchangeRates + .Where(r => r.Code == code && r.Date <= date) + .OrderByDescending(r => r.Date) + .Select(r => new RateDto(r.Code, r.Date, r.RateToUah)) + .FirstOrDefaultAsync(ct); + return Ok(row); + } + + /// Current user's display preferences (creates defaults if missing). + [HttpGet("preferences")] + public async Task GetPreferences(CancellationToken ct) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) return Unauthorized(); + var row = await _db.UserPreferences.FirstOrDefaultAsync(p => p.UserId == userId, ct); + return Ok(new PreferencesDto(row?.PreferredCurrency ?? "UAH")); + } + + [HttpPut("preferences")] + public async Task UpdatePreferences([FromBody] UpdatePreferencesRequest req, CancellationToken ct) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) return Unauthorized(); + var code = (req.PreferredCurrency ?? string.Empty).ToUpperInvariant(); + if (!AllowedCodes.Contains(code)) + return BadRequest(new { error = "PreferredCurrency must be one of UAH, USD, EUR" }); + + var existing = await _db.UserPreferences.FirstOrDefaultAsync(p => p.UserId == userId, ct); + if (existing is null) + { + _db.UserPreferences.Add(new UserPreferences + { + UserId = userId, + PreferredCurrency = code, + UpdatedAtUtc = DateTime.UtcNow, + }); + } + else + { + existing.PreferredCurrency = code; + existing.UpdatedAtUtc = DateTime.UtcNow; + } + await _db.SaveChangesAsync(ct); + return Ok(new PreferencesDto(code)); + } +} diff --git a/src/AgroPlatform.Api/Controllers/MeController.cs b/src/AgroPlatform.Api/Controllers/MeController.cs index d4cc1487..3bece6e3 100644 --- a/src/AgroPlatform.Api/Controllers/MeController.cs +++ b/src/AgroPlatform.Api/Controllers/MeController.cs @@ -43,6 +43,7 @@ public async Task Get(CancellationToken cancellationToken) var userId = _currentUser.UserId; bool isSuperAdmin = _currentUser.IsSuperAdmin; bool mfaEnabled = false; + string preferredCurrency = "UAH"; if (!string.IsNullOrEmpty(userId)) { @@ -52,6 +53,9 @@ public async Task Get(CancellationToken cancellationToken) // Prefer the DB flag over the JWT claim (claim may be stale across logins). var user = await _userManager.FindByIdAsync(userId); if (user is not null) isSuperAdmin = user.IsSuperAdmin || _currentUser.IsSuperAdmin; + + var prefs = await _db.UserPreferences.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); + if (prefs is not null) preferredCurrency = prefs.PreferredCurrency; } return Ok(new @@ -64,6 +68,7 @@ public async Task Get(CancellationToken cancellationToken) features, isSuperAdmin, mfaEnabled, + preferredCurrency, // Super-admin without MFA enrolled → SPA redirects to /setup-mfa. mfaRequired = isSuperAdmin && !mfaEnabled, }); diff --git a/src/AgroPlatform.Application/Common/Interfaces/IAppDbContext.cs b/src/AgroPlatform.Application/Common/Interfaces/IAppDbContext.cs index 249d2bc4..4966fabf 100644 --- a/src/AgroPlatform.Application/Common/Interfaces/IAppDbContext.cs +++ b/src/AgroPlatform.Application/Common/Interfaces/IAppDbContext.cs @@ -76,8 +76,10 @@ public interface IAppDbContext DbSet ApiKeys { get; } DbSet RefreshTokens { get; } DbSet UserMfaSettings { get; } + DbSet UserPreferences { get; } DbSet SuperAdminAuditLogs { get; } DbSet Seasons { get; } + DbSet ExchangeRates { get; } // Approval workflow DbSet ApprovalRules { get; } diff --git a/src/AgroPlatform.Application/Common/Interfaces/INbuCurrencyService.cs b/src/AgroPlatform.Application/Common/Interfaces/INbuCurrencyService.cs new file mode 100644 index 00000000..ba90fdfa --- /dev/null +++ b/src/AgroPlatform.Application/Common/Interfaces/INbuCurrencyService.cs @@ -0,0 +1,34 @@ +namespace AgroPlatform.Application.Common.Interfaces; + +/// +/// Integrates with the National Bank of Ukraine exchange rates endpoint. +/// Rates are stored in ExchangeRates with UAH as the base. +/// See ROADMAP.md "Decisions locked / Currency" for scope and fallback rules. +/// +public interface INbuCurrencyService +{ + /// + /// Returns the rate (UAH per 1 unit of ) for . + /// When the exact date is not present (weekend/holiday), returns the most recent + /// earlier stored rate. Returns null when no rate for the currency exists at all. + /// + Task GetRateAsync(string code, DateOnly date, CancellationToken ct = default); + + /// + /// Returns the latest stored rate for , or null. + /// + Task GetLatestRateAsync(string code, CancellationToken ct = default); + + /// + /// Fetches today's rate(s) from NBU for every supported currency and upserts them. + /// Safe to call multiple times per day; idempotent. + /// On NBU failure, logs a warning and leaves existing rows untouched. + /// + Task SyncDailyAsync(CancellationToken ct = default); + + /// + /// Fetches the rate range [, ] for a single + /// currency and upserts rows. Used by the backfill tool and tests. + /// + Task BackfillAsync(string code, DateOnly from, DateOnly to, CancellationToken ct = default); +} diff --git a/src/AgroPlatform.Domain/Economics/ExchangeRate.cs b/src/AgroPlatform.Domain/Economics/ExchangeRate.cs new file mode 100644 index 00000000..5b2d2e32 --- /dev/null +++ b/src/AgroPlatform.Domain/Economics/ExchangeRate.cs @@ -0,0 +1,22 @@ +namespace AgroPlatform.Domain.Economics; + +/// +/// NBU-published daily exchange rate of a foreign currency against UAH. +/// Base currency in the system is UAH; this table stores historical rates for +/// presentation-layer conversion only (per PR #613 / ROADMAP locked decisions). +/// Composite primary key: (Code, Date). +/// +public class ExchangeRate +{ + /// ISO 4217 currency code, e.g. "USD", "EUR". + public string Code { get; set; } = string.Empty; + + /// Date the NBU rate applies to (Kyiv local date). + public DateOnly Date { get; set; } + + /// How many UAH per 1 unit of . + public decimal RateToUah { get; set; } + + /// Timestamp when the row was fetched/written. Diagnostics only. + public DateTime FetchedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/src/AgroPlatform.Domain/Users/UserPreferences.cs b/src/AgroPlatform.Domain/Users/UserPreferences.cs new file mode 100644 index 00000000..0d04e07e --- /dev/null +++ b/src/AgroPlatform.Domain/Users/UserPreferences.cs @@ -0,0 +1,16 @@ +namespace AgroPlatform.Domain.Users; + +/// +/// Per-user presentation preferences. One row per . +/// Deliberately scoped to display concerns — stored values in DB remain in UAH. +/// +public class UserPreferences +{ + /// FK to AspNetUsers.Id (string — Identity default). + public string UserId { get; set; } = string.Empty; + + /// ISO 4217 display currency. Allowed: UAH, USD, EUR. Default UAH. + public string PreferredCurrency { get; set; } = "UAH"; + + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/src/AgroPlatform.Infrastructure/DependencyInjection.cs b/src/AgroPlatform.Infrastructure/DependencyInjection.cs index 4f11eb5d..1a96113a 100644 --- a/src/AgroPlatform.Infrastructure/DependencyInjection.cs +++ b/src/AgroPlatform.Infrastructure/DependencyInjection.cs @@ -58,10 +58,18 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); + // Currency (NBU) + services.AddHttpClient(c => + { + c.Timeout = TimeSpan.FromSeconds(15); + c.DefaultRequestHeaders.UserAgent.ParseAdd("AgroPlatform/1.0"); + }); + // Background automation jobs services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); services.AddIdentity(options => { diff --git a/src/AgroPlatform.Infrastructure/Persistence/AppDbContext.cs b/src/AgroPlatform.Infrastructure/Persistence/AppDbContext.cs index 065f0ba6..ae630b93 100644 --- a/src/AgroPlatform.Infrastructure/Persistence/AppDbContext.cs +++ b/src/AgroPlatform.Infrastructure/Persistence/AppDbContext.cs @@ -84,8 +84,10 @@ public AppDbContext(DbContextOptions options, ITenantService tenan public DbSet ApiKeys => Set(); public DbSet RefreshTokens => Set(); public DbSet UserMfaSettings => Set(); + public DbSet UserPreferences => Set(); public DbSet SuperAdminAuditLogs => Set(); public DbSet Seasons => Set(); + public DbSet ExchangeRates => Set(); // Approval workflow public DbSet ApprovalRules => Set(); diff --git a/src/AgroPlatform.Infrastructure/Persistence/Configurations/ExchangeRateConfiguration.cs b/src/AgroPlatform.Infrastructure/Persistence/Configurations/ExchangeRateConfiguration.cs new file mode 100644 index 00000000..b2950479 --- /dev/null +++ b/src/AgroPlatform.Infrastructure/Persistence/Configurations/ExchangeRateConfiguration.cs @@ -0,0 +1,22 @@ +using AgroPlatform.Domain.Economics; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AgroPlatform.Infrastructure.Persistence.Configurations; + +public class ExchangeRateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ExchangeRates"); + + builder.HasKey(x => new { x.Code, x.Date }); + + builder.Property(x => x.Code).IsRequired().HasMaxLength(3); + builder.Property(x => x.Date).IsRequired(); + builder.Property(x => x.RateToUah).IsRequired().HasColumnType("numeric(18,6)"); + builder.Property(x => x.FetchedAtUtc).IsRequired(); + + builder.HasIndex(x => x.Date); + } +} diff --git a/src/AgroPlatform.Infrastructure/Persistence/Configurations/UserPreferencesConfiguration.cs b/src/AgroPlatform.Infrastructure/Persistence/Configurations/UserPreferencesConfiguration.cs new file mode 100644 index 00000000..824964f7 --- /dev/null +++ b/src/AgroPlatform.Infrastructure/Persistence/Configurations/UserPreferencesConfiguration.cs @@ -0,0 +1,24 @@ +using AgroPlatform.Domain.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AgroPlatform.Infrastructure.Persistence.Configurations; + +public class UserPreferencesConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("UserPreferences"); + + builder.HasKey(x => x.UserId); + + builder.Property(x => x.UserId).IsRequired().HasMaxLength(450); + builder.Property(x => x.PreferredCurrency).IsRequired().HasMaxLength(3).HasDefaultValue("UAH"); + builder.Property(x => x.UpdatedAtUtc).IsRequired(); + + builder.HasOne() + .WithOne() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/AgroPlatform.Infrastructure/Persistence/Migrations/20260424095222_AddCurrencyAndUserPreferences.Designer.cs b/src/AgroPlatform.Infrastructure/Persistence/Migrations/20260424095222_AddCurrencyAndUserPreferences.Designer.cs new file mode 100644 index 00000000..e02e6d47 --- /dev/null +++ b/src/AgroPlatform.Infrastructure/Persistence/Migrations/20260424095222_AddCurrencyAndUserPreferences.Designer.cs @@ -0,0 +1,4724 @@ +// +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("20260424095222_AddCurrencyAndUserPreferences")] + partial class AddCurrencyAndUserPreferences + { + /// + 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.Economics.ExchangeRate", b => + { + b.Property("Code") + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FetchedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RateToUah") + .HasColumnType("numeric(18,6)"); + + b.HasKey("Code", "Date"); + + b.HasIndex("Date"); + + b.ToTable("ExchangeRates", (string)null); + }); + + 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.Users.UserPreferences", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("PreferredCurrency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("UAH"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("UserPreferences", (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.Users.UserPreferences", b => + { + b.HasOne("AgroPlatform.Domain.Users.AppUser", null) + .WithOne() + .HasForeignKey("AgroPlatform.Domain.Users.UserPreferences", "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/20260424095222_AddCurrencyAndUserPreferences.cs b/src/AgroPlatform.Infrastructure/Persistence/Migrations/20260424095222_AddCurrencyAndUserPreferences.cs new file mode 100644 index 00000000..410dafb3 --- /dev/null +++ b/src/AgroPlatform.Infrastructure/Persistence/Migrations/20260424095222_AddCurrencyAndUserPreferences.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AgroPlatform.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddCurrencyAndUserPreferences : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ExchangeRates", + columns: table => new + { + Code = table.Column(type: "character varying(3)", maxLength: 3, nullable: false), + Date = table.Column(type: "date", nullable: false), + RateToUah = table.Column(type: "numeric(18,6)", nullable: false), + FetchedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExchangeRates", x => new { x.Code, x.Date }); + }); + + migrationBuilder.CreateTable( + name: "UserPreferences", + columns: table => new + { + UserId = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + PreferredCurrency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false, defaultValue: "UAH"), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserPreferences", x => x.UserId); + table.ForeignKey( + name: "FK_UserPreferences_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ExchangeRates_Date", + table: "ExchangeRates", + column: "Date"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ExchangeRates"); + + migrationBuilder.DropTable( + name: "UserPreferences"); + } + } +} diff --git a/src/AgroPlatform.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/AgroPlatform.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 03697063..6ed5ad86 100644 --- a/src/AgroPlatform.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/AgroPlatform.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -595,6 +595,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("CostRecords"); }); + modelBuilder.Entity("AgroPlatform.Domain.Economics.ExchangeRate", b => + { + b.Property("Code") + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FetchedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RateToUah") + .HasColumnType("numeric(18,6)"); + + b.HasKey("Code", "Date"); + + b.HasIndex("Date"); + + b.ToTable("ExchangeRates", (string)null); + }); + modelBuilder.Entity("AgroPlatform.Domain.FeatureFlags.TenantFeatureFlag", b => { b.Property("TenantId") @@ -3230,6 +3252,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserMfaSettings", (string)null); }); + modelBuilder.Entity("AgroPlatform.Domain.Users.UserPreferences", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("PreferredCurrency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("UAH"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("UserPreferences", (string)null); + }); + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.Batch", b => { b.Property("Id") @@ -4385,6 +4428,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("AgroPlatform.Domain.Users.UserPreferences", b => + { + b.HasOne("AgroPlatform.Domain.Users.AppUser", null) + .WithOne() + .HasForeignKey("AgroPlatform.Domain.Users.UserPreferences", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("AgroPlatform.Domain.Warehouses.Batch", b => { b.HasOne("AgroPlatform.Domain.Warehouses.WarehouseItem", "Item") diff --git a/src/AgroPlatform.Infrastructure/Services/BackgroundJobs/NbuDailySyncJob.cs b/src/AgroPlatform.Infrastructure/Services/BackgroundJobs/NbuDailySyncJob.cs new file mode 100644 index 00000000..2048b3e5 --- /dev/null +++ b/src/AgroPlatform.Infrastructure/Services/BackgroundJobs/NbuDailySyncJob.cs @@ -0,0 +1,69 @@ +using AgroPlatform.Application.Common.Interfaces; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace AgroPlatform.Infrastructure.Services.BackgroundJobs; + +/// +/// Daily NBU exchange rate sync. Runs once per day at 06:00 Europe/Kyiv +/// (per ROADMAP "Decisions locked / Currency"). +/// +public sealed class NbuDailySyncJob : BackgroundService +{ + private readonly IServiceProvider _services; + private readonly ILogger _logger; + + // The Linux build image supplies IANA zone data. If the zone is missing + // (e.g. minimal container), fall back to +02:00 fixed offset, which is + // close enough for a once-a-day window and still schedules the job. + private static readonly TimeZoneInfo KyivTz = ResolveKyiv(); + + public NbuDailySyncJob(IServiceProvider services, ILogger logger) + { + _services = services; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Small startup delay; lets migrations finish first. + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = _services.CreateScope(); + var nbu = scope.ServiceProvider.GetRequiredService(); + await nbu.SyncDailyAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "NBU daily sync job iteration failed; will retry at next 06:00 Kyiv."); + } + + var delay = TimeUntilNext06Kyiv(DateTime.UtcNow); + await Task.Delay(delay, stoppingToken); + } + } + + public static TimeSpan TimeUntilNext06Kyiv(DateTime nowUtc) + { + var nowKyiv = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, KyivTz); + var todayAt6 = new DateTime(nowKyiv.Year, nowKyiv.Month, nowKyiv.Day, 6, 0, 0, DateTimeKind.Unspecified); + var nextKyiv = nowKyiv < todayAt6 ? todayAt6 : todayAt6.AddDays(1); + var nextUtc = TimeZoneInfo.ConvertTimeToUtc(nextKyiv, KyivTz); + var diff = nextUtc - nowUtc; + return diff < TimeSpan.FromSeconds(1) ? TimeSpan.FromMinutes(1) : diff; + } + + private static TimeZoneInfo ResolveKyiv() + { + try { return TimeZoneInfo.FindSystemTimeZoneById("Europe/Kyiv"); } + catch (TimeZoneNotFoundException) { } + try { return TimeZoneInfo.FindSystemTimeZoneById("Europe/Kiev"); } + catch (TimeZoneNotFoundException) { } + return TimeZoneInfo.CreateCustomTimeZone("Kyiv-fixed", TimeSpan.FromHours(2), "Kyiv", "Kyiv"); + } +} diff --git a/src/AgroPlatform.Infrastructure/Services/NbuCurrencyService.cs b/src/AgroPlatform.Infrastructure/Services/NbuCurrencyService.cs new file mode 100644 index 00000000..83f86be3 --- /dev/null +++ b/src/AgroPlatform.Infrastructure/Services/NbuCurrencyService.cs @@ -0,0 +1,139 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AgroPlatform.Application.Common.Interfaces; +using AgroPlatform.Domain.Economics; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace AgroPlatform.Infrastructure.Services; + +/// +/// NBU currency rate integration per ROADMAP "Decisions locked / Currency": +/// endpoint https://bank.gov.ua/NBU_Exchange/exchange_site?start=YYYYMMDD&end=YYYYMMDD&valcode=USD&json. +/// +public sealed class NbuCurrencyService : INbuCurrencyService +{ + /// Currencies we track for display conversion. + public static readonly string[] SupportedCodes = { "USD", "EUR" }; + + private readonly HttpClient _http; + private readonly IServiceProvider _services; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public NbuCurrencyService(HttpClient http, IServiceProvider services, ILogger logger) + { + _http = http; + _services = services; + _logger = logger; + } + + public async Task GetRateAsync(string code, DateOnly date, CancellationToken ct = default) + { + using var scope = _services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.ExchangeRates + .Where(r => r.Code == code && r.Date <= date) + .OrderByDescending(r => r.Date) + .Select(r => (decimal?)r.RateToUah) + .FirstOrDefaultAsync(ct); + } + + public async Task GetLatestRateAsync(string code, CancellationToken ct = default) + { + using var scope = _services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.ExchangeRates + .Where(r => r.Code == code) + .OrderByDescending(r => r.Date) + .Select(r => (decimal?)r.RateToUah) + .FirstOrDefaultAsync(ct); + } + + public async Task SyncDailyAsync(CancellationToken ct = default) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + foreach (var code in SupportedCodes) + { + try + { + await BackfillAsync(code, today, today, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "NBU daily sync failed for {Code}; last stored rate will be used.", code); + } + } + } + + public async Task BackfillAsync(string code, DateOnly from, DateOnly to, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(code)) throw new ArgumentException("code required", nameof(code)); + if (to < from) throw new ArgumentException("to must be >= from"); + + var url = $"https://bank.gov.ua/NBU_Exchange/exchange_site?start={from:yyyyMMdd}&end={to:yyyyMMdd}&valcode={code}&sort=exchangedate&order=desc&json"; + using var response = await _http.GetAsync(url, ct); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(ct); + var rows = await JsonSerializer.DeserializeAsync>(stream, JsonOpts, ct) + ?? new List(); + + if (rows.Count == 0) return 0; + + using var scope = _services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var parsed = new List(); + foreach (var row in rows) + { + if (string.IsNullOrWhiteSpace(row.Cc) || row.Rate <= 0) continue; + if (!DateOnly.TryParseExact(row.ExchangeDate, "dd.MM.yyyy", out var date)) continue; + parsed.Add(new ExchangeRate + { + Code = row.Cc.ToUpperInvariant(), + Date = date, + RateToUah = row.Rate, + FetchedAtUtc = DateTime.UtcNow, + }); + } + + if (parsed.Count == 0) return 0; + + // Upsert: load existing rows in window, update amounts; insert new ones. + var codes = parsed.Select(p => p.Code).Distinct().ToList(); + var dates = parsed.Select(p => p.Date).Distinct().ToList(); + var existing = await db.ExchangeRates + .Where(r => codes.Contains(r.Code) && dates.Contains(r.Date)) + .ToListAsync(ct); + + var existingMap = existing.ToDictionary(e => (e.Code, e.Date)); + foreach (var p in parsed) + { + if (existingMap.TryGetValue((p.Code, p.Date), out var row)) + { + row.RateToUah = p.RateToUah; + row.FetchedAtUtc = p.FetchedAtUtc; + } + else + { + db.ExchangeRates.Add(p); + } + } + + await db.SaveChangesAsync(ct); + return parsed.Count; + } + + private sealed class NbuRow + { + [JsonPropertyName("exchangedate")] public string ExchangeDate { get; set; } = string.Empty; + [JsonPropertyName("cc")] public string Cc { get; set; } = string.Empty; + [JsonPropertyName("rate")] public decimal Rate { get; set; } + } +} diff --git a/tests/AgroPlatform.IntegrationTests/Currency/CurrencyTests.cs b/tests/AgroPlatform.IntegrationTests/Currency/CurrencyTests.cs new file mode 100644 index 00000000..e034665c --- /dev/null +++ b/tests/AgroPlatform.IntegrationTests/Currency/CurrencyTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using System.Net.Http.Json; +using AgroPlatform.Domain.Economics; +using AgroPlatform.Domain.Enums; +using AgroPlatform.Domain.Users; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace AgroPlatform.IntegrationTests.Currency; + +[Collection("Integration Tests")] +public sealed class CurrencyTests : IntegrationTestBase +{ + public CurrencyTests(CustomWebApplicationFactory factory) : base(factory) { } + + private sealed record RateDto(string Code, DateOnly Date, decimal RateToUah); + private sealed record PrefsDto(string PreferredCurrency); + + // NOTE: ExchangeRates is a global (non-tenant-scoped) table; tests in this xUnit collection + // share a single Testcontainers Postgres instance. To avoid PK collisions between tests, + // each test uses distinct dates far in the future and upserts idempotently. + + private async Task EnsureTestUserAsync() + { + using var scope = CreateScope(); + var db = GetDbContext(scope); + var userId = TestAuthHandler.TestUserId.ToString(); + var exists = await db.Users.IgnoreQueryFilters().AnyAsync(u => u.Id == userId); + if (!exists) + { + db.Users.Add(new AppUser + { + Id = userId, + UserName = "currency-test@example.com", + NormalizedUserName = "CURRENCY-TEST@EXAMPLE.COM", + Email = "currency-test@example.com", + NormalizedEmail = "CURRENCY-TEST@EXAMPLE.COM", + EmailConfirmed = true, + SecurityStamp = Guid.NewGuid().ToString(), + ConcurrencyStamp = Guid.NewGuid().ToString(), + FirstName = "Currency", + LastName = "Test", + Role = UserRole.CompanyAdmin, + TenantId = TenantId, + IsActive = true, + }); + await db.SaveChangesAsync(); + } + } + + private async Task UpsertRateAsync(string code, DateOnly date, decimal rateToUah) + { + using var scope = CreateScope(); + var db = GetDbContext(scope); + var existing = await db.ExchangeRates.FirstOrDefaultAsync(r => r.Code == code && r.Date == date); + if (existing is null) + { + db.ExchangeRates.Add(new ExchangeRate { Code = code, Date = date, RateToUah = rateToUah, FetchedAtUtc = DateTime.UtcNow }); + } + else + { + existing.RateToUah = rateToUah; + existing.FetchedAtUtc = DateTime.UtcNow; + } + await db.SaveChangesAsync(); + } + + [Fact] + public async Task GetLatestRates_ReturnsPerCurrencyMostRecentRow() + { + // Use dates far in the future so this test's rows dominate. + var d1 = new DateOnly(2099, 1, 10); + var d2 = new DateOnly(2099, 1, 11); + await UpsertRateAsync("USD", d1, 98.10m); + await UpsertRateAsync("USD", d2, 99.20m); + await UpsertRateAsync("EUR", d2, 101.30m); + + using var client = Factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Tenant-Id", TenantId.ToString()); + var rates = await client.GetFromJsonAsync>("/api/currency/rates/latest", JsonOptions); + + rates.Should().NotBeNull(); + rates!.First(r => r.Code == "USD").RateToUah.Should().Be(99.20m); + rates.First(r => r.Code == "EUR").RateToUah.Should().Be(101.30m); + } + + [Fact] + public async Task GetRate_FallsBackToPreviousBusinessDay() + { + // 2098-06-19 is a Friday; the next day (Saturday) has no rate. + var friday = new DateOnly(2098, 6, 19); + var saturday = friday.AddDays(1); + await UpsertRateAsync("USD", friday, 41.70m); + + using var client = Factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Tenant-Id", TenantId.ToString()); + var rate = await client.GetFromJsonAsync( + $"/api/currency/rates?code=USD&date={saturday:yyyy-MM-dd}", JsonOptions); + + rate.Should().NotBeNull(); + rate!.Date.Should().Be(friday); + rate.RateToUah.Should().Be(41.70m); + } + + [Fact] + public async Task GetRate_ForUah_ReturnsOne() + { + using var client = Factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Tenant-Id", TenantId.ToString()); + var rate = await client.GetFromJsonAsync("/api/currency/rates?code=UAH&date=2026-04-25", JsonOptions); + rate!.RateToUah.Should().Be(1m); + } + + [Fact] + public async Task UpdatePreferences_ValidCurrency_PersistsAndIsReadable() + { + await EnsureTestUserAsync(); + using var client = Factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Tenant-Id", TenantId.ToString()); + + var put = await client.PutAsJsonAsync("/api/currency/preferences", new { preferredCurrency = "USD" }, JsonOptions); + put.StatusCode.Should().Be(HttpStatusCode.OK); + + var get = await client.GetFromJsonAsync("/api/currency/preferences", JsonOptions); + get!.PreferredCurrency.Should().Be("USD"); + + // Revert to default so this test is idempotent across repeated runs. + var revert = await client.PutAsJsonAsync("/api/currency/preferences", new { preferredCurrency = "UAH" }, JsonOptions); + revert.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task UpdatePreferences_InvalidCurrency_Returns400() + { + await EnsureTestUserAsync(); + using var client = Factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Tenant-Id", TenantId.ToString()); + + var put = await client.PutAsJsonAsync("/api/currency/preferences", new { preferredCurrency = "GBP" }, JsonOptions); + put.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } +} diff --git a/tests/AgroPlatform.UnitTests/Economics/NbuCurrencyServiceTests.cs b/tests/AgroPlatform.UnitTests/Economics/NbuCurrencyServiceTests.cs new file mode 100644 index 00000000..be4d5e02 --- /dev/null +++ b/tests/AgroPlatform.UnitTests/Economics/NbuCurrencyServiceTests.cs @@ -0,0 +1,130 @@ +using AgroPlatform.Application.Common.Interfaces; +using AgroPlatform.Domain.Economics; +using AgroPlatform.Infrastructure.Services; +using AgroPlatform.Infrastructure.Services.BackgroundJobs; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using System.Net; +using System.Text; + +namespace AgroPlatform.UnitTests.Economics; + +public class NbuCurrencyServiceTests +{ + private static (NbuCurrencyService svc, TestDbContext db) Build(string payload, HttpStatusCode status = HttpStatusCode.OK) + { + var handler = new FakeHttpHandler(payload, status); + var http = new HttpClient(handler); + + var dbOpts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var db = new TestDbContext(dbOpts); + + var services = new ServiceCollection(); + services.AddSingleton(db); + var sp = services.BuildServiceProvider(); + + var svc = new NbuCurrencyService(http, sp, NullLogger.Instance); + return (svc, db); + } + + [Fact] + public async Task BackfillAsync_UpsertsRatesFromNbuResponse() + { + const string payload = "[{\"exchangedate\":\"23.04.2026\",\"cc\":\"USD\",\"rate\":41.5}," + + "{\"exchangedate\":\"24.04.2026\",\"cc\":\"USD\",\"rate\":41.7}]"; + var (svc, db) = Build(payload); + + var inserted = await svc.BackfillAsync("USD", new DateOnly(2026, 4, 23), new DateOnly(2026, 4, 24)); + + inserted.Should().Be(2); + var rows = await db.ExchangeRates.OrderBy(r => r.Date).ToListAsync(); + rows.Should().HaveCount(2); + rows[0].Code.Should().Be("USD"); + rows[0].RateToUah.Should().Be(41.5m); + rows[1].RateToUah.Should().Be(41.7m); + } + + [Fact] + public async Task BackfillAsync_SecondRunUpdatesExistingRowInPlace() + { + const string payload1 = "[{\"exchangedate\":\"24.04.2026\",\"cc\":\"USD\",\"rate\":41.0}]"; + var (svc, db) = Build(payload1); + await svc.BackfillAsync("USD", new DateOnly(2026, 4, 24), new DateOnly(2026, 4, 24)); + + // Now swap handler payload (via reflection into svc would be ugly — recreate with same db). + var handler2 = new FakeHttpHandler("[{\"exchangedate\":\"24.04.2026\",\"cc\":\"USD\",\"rate\":42.1}]"); + var http2 = new HttpClient(handler2); + var services = new ServiceCollection(); + services.AddSingleton(db); + var sp = services.BuildServiceProvider(); + var svc2 = new NbuCurrencyService(http2, sp, NullLogger.Instance); + + await svc2.BackfillAsync("USD", new DateOnly(2026, 4, 24), new DateOnly(2026, 4, 24)); + + var rows = await db.ExchangeRates.ToListAsync(); + rows.Should().HaveCount(1); + rows[0].RateToUah.Should().Be(42.1m); + } + + [Fact] + public async Task GetRateAsync_FallsBackToMostRecentEarlierRate_OnWeekend() + { + var (svc, db) = Build("[]"); + db.ExchangeRates.Add(new ExchangeRate { Code = "USD", Date = new DateOnly(2026, 4, 24), RateToUah = 41.5m }); + await db.SaveChangesAsync(); + + // Request Saturday (25 Apr) → no direct row → falls back to Friday rate. + var rate = await svc.GetRateAsync("USD", new DateOnly(2026, 4, 25)); + + rate.Should().Be(41.5m); + } + + [Fact] + public async Task GetRateAsync_ReturnsNull_WhenNoRatesAtAllForCurrency() + { + var (svc, _) = Build("[]"); + var rate = await svc.GetRateAsync("GBP", new DateOnly(2026, 4, 24)); + rate.Should().BeNull(); + } + + [Fact] + public async Task BackfillAsync_EmptyResponse_DoesNotThrow_AndInsertsNothing() + { + var (svc, db) = Build("[]"); + var n = await svc.BackfillAsync("USD", new DateOnly(2026, 4, 24), new DateOnly(2026, 4, 24)); + n.Should().Be(0); + (await db.ExchangeRates.CountAsync()).Should().Be(0); + } + + [Fact] + public void NbuDailySyncJob_TimeUntilNext06Kyiv_IsBetween1MinuteAnd24Hours() + { + // Arbitrary UTC instant: 2026-04-24 12:00 UTC (15:00 Kyiv DST) → next 06:00 Kyiv is tomorrow ~15h later. + var now = new DateTime(2026, 4, 24, 12, 0, 0, DateTimeKind.Utc); + var delay = NbuDailySyncJob.TimeUntilNext06Kyiv(now); + delay.Should().BeGreaterThan(TimeSpan.FromMinutes(1)); + delay.Should().BeLessThan(TimeSpan.FromHours(24)); + } + + private sealed class FakeHttpHandler : HttpMessageHandler + { + private readonly string _payload; + private readonly HttpStatusCode _status; + + public FakeHttpHandler(string payload, HttpStatusCode status = HttpStatusCode.OK) + { + _payload = payload; + _status = status; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(_status) + { + Content = new StringContent(_payload, Encoding.UTF8, "application/json"), + }); + } +} diff --git a/tests/AgroPlatform.UnitTests/TestDbContext.cs b/tests/AgroPlatform.UnitTests/TestDbContext.cs index d7b1d0ca..ca070c57 100644 --- a/tests/AgroPlatform.UnitTests/TestDbContext.cs +++ b/tests/AgroPlatform.UnitTests/TestDbContext.cs @@ -67,6 +67,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.Property(x => x.Code).HasMaxLength(16); e.Property(x => x.Name).HasMaxLength(100); }); + + modelBuilder.Entity(e => + { + e.HasKey(x => new { x.Code, x.Date }); + e.Property(x => x.Code).HasMaxLength(3); + }); + + modelBuilder.Entity(e => + { + e.HasKey(x => x.UserId); + e.Property(x => x.UserId).HasMaxLength(450); + e.Property(x => x.PreferredCurrency).HasMaxLength(3); + }); } public DbSet Warehouses => Set(); @@ -123,6 +136,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet UserMfaSettings => Set(); public DbSet SuperAdminAuditLogs => Set(); public DbSet Seasons => Set(); + public DbSet ExchangeRates => Set(); + public DbSet UserPreferences => Set(); public DbSet StockLedgerEntries => Set(); public DbSet ItemCategories => Set(); public DbSet InventorySessions => Set();