diff --git a/Nop.Plugin.Misc.AIPlugin/.github/copilot-instructions.md b/Nop.Plugin.Misc.AIPlugin/.github/copilot-instructions.md
new file mode 100644
index 0000000..e69de29
diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs
index 18bcd93..3a93a69 100644
--- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs
+++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs
@@ -46,6 +46,9 @@ public class PluginNopStartup : INopStartup
/// Configuration of the application
public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
+ // Enforce registration of source generated serializers which might not run due to AssemblyLoadContext restrictions
+ Mango.Nop.Core.AcBinaryForcedInit.ForceRegister();
+
services.AddScoped();
services.AddTransient();
diff --git a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj
index 9088276..447e967 100644
--- a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj
+++ b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj
@@ -667,6 +667,15 @@
+
+
+
+
+
+
+
+
+
diff --git a/Nop.Plugin.Misc.AIPlugin/README.md b/Nop.Plugin.Misc.AIPlugin/README.md
index ca173d9..63c05b0 100644
--- a/Nop.Plugin.Misc.AIPlugin/README.md
+++ b/Nop.Plugin.Misc.AIPlugin/README.md
@@ -1,67 +1,59 @@
# Nop.Plugin.Misc.AIPlugin (FruitBank nopCommerce Plugin)
-> For FruitBankHybridApp domain rules see: `../../../../../FruitBankHybridApp/.github/copilot-instructions.md`
-> For Mango.Nop library docs see: `../../Libraries/README.md`
+@project {
+ type = "nopcommerce-plugin"
+ own-dep-projects = [
+ "Mango.Nop.Core, Mango.Nop.Services (in NopCommerce.Common/4.70/Libraries)"
+ ]
+}
-The **server-side nopCommerce plugin** for FruitBank. Runs inside nopCommerce 4.80.9 (net9.0). Handles admin UI, SignalR hubs, data access, measurements, orders, shipping, and AI services.
+> For Mango.Nop library docs see `NopCommerce.Common/4.70/Libraries/README.md`
+> For FruitBankHybridApp domain rules see the FruitBankHybridApp solution's `.github/copilot-instructions.md`
+> For core measurement system rules and common domain traps, see: `../../../../FruitBankHybridApp/FruitBank.Common/docs/GLOSSARY.md`
+
+Server-side nopCommerce plugin for FruitBank, a fruit and vegetable wholesaler. Manages supplier inbound delivery (receiving), warehouse weighing (net/gross/pallet/tare weights), outbound order measurement, inventory stocktaking, AI services, and Billingo invoicing. Runs inside nopCommerce 4.80.9 (**net9.0**).
Project file: `Nop.Plugin.Misc.FruitBankPlugin.csproj`
+## Documentation
+
+| Document | Topic |
+|---|---|
+| `docs/SCHEMA.md` | Authoritative domain model in TOON format — all entities, DTOs, enums, relationships |
+| `docs/DOMAIN_MODEL.md` | Behavioral docs: weight formula, MeasuringStatus lifecycle, GenericAttribute keys, entity hierarchy overview |
+| `docs/MEASUREMENT.md` | Three measurement workflows (inbound shipping, outbound order, stocktaking), MeasuringStatus lifecycle, pricing, checkout filter |
+| `docs/AI_SERVICES.md` | OpenAI, Cerebras, Replicate providers, FruitBankSettings, file storage, PDF conversion, audio transcription |
+| `docs/DATA_LAYER.md` | FruitBankDbContext, StockTakingDbContext, DbTable repositories, entity mapping, FruitBankEventConsumer |
+| `docs/SIGNALR_ENDPOINTS.md` | SignalR endpoints, FruitBankDataController, InnVoice/Billingo integration, FruitBankAttributeService |
+
## Folder Structure
-| Folder | Purpose | Key Types |
-|---|---|---|
-| `Areas/Admin/Controllers/` | Admin-side MVC controllers — order, product, shipping, SignalR endpoints, file management | `CustomOrderController`, `CustomOrderSignalREndpoint`, `ShippingController`, `StockSignalREndpointServer`, `CustomProductController` |
-| `Areas/Admin/Components/` | Admin ViewComponents — DevExtreme grids | `ShippingGridComponent`, `ShippingDocumentGridComponent`, `PartnersGridComponent`, `FileUploadGridComponent` |
-| `Areas/Admin/Models/` | Admin view models — shipping, order, product, app download | `ShippingModel`, `ShippingSearchModel`, `OrderSearchModelExtended`, `GridBaseViewModel` |
-| `Areas/Admin/Views/` | Admin Razor views | |
-| `Components/` | Public-side ViewComponents | |
-| `Controllers/` | Public-side controllers | `CheckoutController`, `FruitBankDataController` |
-| `Domains/` | Entity configuration + data layer | `CustomTable`, `FruitBankDbContext` |
-| `Domains/DataLayer/` | DbTable classes — EF Core repository per entity | `ShippingDbTable`, `OrderDtoDbTable`, `ShippingItemPalletDbTable`, etc. |
-| `Domains/DataLayer/Interfaces/` | Repository interfaces | `IShippingDbSet`, `IOrderDtoDbSet`, `IShippingItemPalletDbSet`, etc. |
-| `Domains/EventConsumers/` | nopCommerce entity event handlers | `FruitBankEventConsumer` |
-| `Factories/` | Model factories — build admin models from entities | `ShippingModelFactory`, `MgOrderModelFactory`, `MgProductModelFactory` |
-| `Filters/` | MVC action filters | `PendingMeasurementCheckoutFilter` |
-| `Helpers/` | Utility helpers | `TextHelper` |
-| `Infrastructure/` | Plugin startup, routing, view engine | `PluginNopStartup`, `RouteProvider`, `ViewLocationExpander` |
-| `Mapping/` | EF Core entity mapping builders | `PluginBuilder`, `NameCompatibility` |
-| `Migrations/` | Database schema migrations | `SchemaMigration` |
-| `Models/` | Shared models — AI chat, MgBase extensions | `AIChatMessage`, `MgOrderModelExtended`, `MgProductModelExtended` |
-| `Models/Orders/` | Order-specific admin models | `OrderModelExtended`, `OrderAttributesModel`, `OrderRevisionModel` |
-| `Models/Products/` | Product-specific admin models | `ProductModelExtended`, `ProductAttributesModel` |
-| `Services/` | Business logic services | See below |
-
-## Key Services
-
-| Service | Purpose |
+| Folder | Purpose |
|---|---|
-| `MeasurementService` / `IMeasurementService` | Shipping measurement logic (weighing pallets) |
-| `OrderMeasurementService` | Order measurement logic (outbound weighing) |
-| `FruitBankHub` | SignalR hub — real-time communication with FruitBankHybridApp clients |
-| `FruitBankAttributeService` | GenericAttribute CRUD for FruitBank-specific attributes |
-| `FileStorageService` / `IFileStorageProvider` | File upload/download, local storage |
-| `LockService` / `ILockService` | Distributed locking |
-| `CustomPriceCalculationService` | Custom price calculation override |
-| `AICalculationService` | AI-powered calculations |
-| `OpenAIApiService` / `OpenAIService` | OpenAI GPT integration |
-| `CerebrasAPIService` | Cerebras AI API integration |
-| `InnvoiceApiService` / `InnVoiceOrderService` | Billingo/Innvoice integration |
-| `PdfToImageService` | PDF rendering to images |
-
-## SignalR Endpoints
-
-The plugin exposes SignalR endpoints consumed by FruitBankHybridApp:
-
-| Endpoint | File | Role |
-|---|---|---|
-| `CustomOrderSignalREndpoint` | `Areas/Admin/Controllers/` | Order CRUD + measurement via SignalR |
-| `StockSignalREndpointServer` | `Areas/Admin/Controllers/` | StockTaking CRUD via SignalR |
-| `FruitBankHub` | `Services/` | Main SignalR hub |
+| `Areas/Admin/Controllers/` | Admin MVC controllers — order, product, shipping, SignalR endpoints, file management |
+| `Areas/Admin/Components/` | Admin ViewComponents — DevExtreme grids |
+| `Areas/Admin/Models/` | Admin view models |
+| `Areas/Admin/Views/` | Admin Razor views |
+| `Components/` | Public-side ViewComponents |
+| `Controllers/` | Public-side controllers — `FruitBankDataController`, `CheckoutController` |
+| `Domains/DataLayer/` | DbContexts, DbTable repositories, interfaces |
+| `Domains/EventConsumers/` | nopCommerce entity event handlers |
+| `Factories/` | Model factories (order, product, shipping) |
+| `Filters/` | `PendingMeasurementCheckoutFilter` |
+| `Infrastructure/` | `PluginNopStartup` (DI), `RouteProvider`, `ViewLocationExpander` |
+| `Mapping/` | EF Core entity mapping, `PluginBuilder` |
+| `Migrations/` | FluentMigrator database schema |
+| `Models/` | Shared models — AI chat, MgBase extensions, order/product models |
+| `Services/` | Business logic — AI, measurement, file storage, invoicing, locking |
## Dependencies
- `Mango.Nop.Core`, `Mango.Nop.Services` (ProjectReferences via `../../Libraries/`)
- `Nop.Services`, `Nop.Web` (nopCommerce ProjectReferences)
-- DevExpress ASP.NET Core 25.1.3
+- AyCode.Core solution assemblies (DLL HintPaths in .csproj)
+- DevExpress ASP.NET Core 25.1.3, DevExtreme 25.1.3
- SignalR, EF Core 9.0, PdfPig, PDFtoImage
+
+## Initialization & Quirks
+
+- **AcBinary Source Generator Registration**: NopCommerce plugins are loaded dynamically via `AssemblyLoadContext`, which prevents the .NET runtime from executing `[ModuleInitializer]` attributes. To ensure the high-performance binary serializers are registered (instead of falling back to slow reflection), `PluginNopStartup.ConfigureServices` must call `Mango.Nop.Core.AcBinaryForcedInit.ForceRegister()`.
diff --git a/Nop.Plugin.Misc.AIPlugin/docs/AI_SERVICES.md b/Nop.Plugin.Misc.AIPlugin/docs/AI_SERVICES.md
new file mode 100644
index 0000000..1f56b91
--- /dev/null
+++ b/Nop.Plugin.Misc.AIPlugin/docs/AI_SERVICES.md
@@ -0,0 +1,155 @@
+# AI Services, File Storage, and PDF
+
+> Part of `Nop.Plugin.Misc.FruitBankPlugin`. See `README.md` for project overview.
+
+## FruitBankSettings
+
+Plugin configuration stored in nopCommerce settings table (`ISettings`). Retrieved via `ISettingService`.
+
+| Property | Type | Purpose |
+|---|---|---|
+| `ApiKey` | string | Cerebras API key |
+| `ModelName` | string | Cerebras model name |
+| `ApiBaseUrl` | string | Cerebras API base URL |
+| `OpenAIApiKey` | string | OpenAI API key |
+| `OpenAIModelName` | string | OpenAI model name |
+| `OpenAIApiBaseUrl` | string | OpenAI API base URL |
+| `MaxTokens` | int | Max response tokens |
+| `Temperature` | double | Sampling temperature |
+| `RequestTimeoutSeconds` | int | HTTP timeout |
+| `IsEnabled` | bool | Plugin enabled flag |
+
+Configured via admin UI: `FruitBankPluginAdminController` → Configure view.
+
+## IAIAPIService Interface
+
+Common contract for AI providers.
+
+| Method | Signature | Purpose |
+|---|---|---|
+| `GetSimpleResponseAsync` | `(List, CancellationToken?) -> Task` | Non-streaming chat completion |
+| `GetStreamedResponseAsync` | `(List, CancellationToken?) -> IAsyncEnumerable` | Streaming chat completion |
+| `GetApiKey` | `() -> string` | Returns configured API key |
+| `GetModelName` | `() -> string` | Returns configured model name |
+
+**Chat message model:**
+
+```
+AIChatMessage { Role: "user"|"assistant"|"system", Content: string }
+```
+
+## OpenAIApiService
+
+Primary AI provider. Implements `IAIAPIService` plus additional capabilities.
+
+| Method | Purpose |
+|---|---|
+| `GetSimpleResponseAsync()` | Chat completion via OpenAI API |
+| `GetStreamedResponseAsync()` | Streaming chat via SSE |
+| `TranscribeAudioAsync(byte[] audioData, string language)` | Whisper API audio-to-text transcription |
+| `GenerateImageAsync(string prompt)` | DALL-E image generation |
+| `ExtractTextFromImageAsync(byte[] imageBytes)` | Vision API — extracts text from image |
+| `AnalyzePdfWithAssistant(byte[] pdfBytes, string prompt)` | Vector store + Assistant API for PDF analysis |
+| `UploadFileAsync(byte[], string purpose)` | File upload to OpenAI |
+| `CreateVectorStoreAsync(string fileId)` | Creates vector store from uploaded file |
+| `CreateThreadAndRunAsync(string vectorStoreId, string prompt)` | Runs assistant with vector store |
+
+## CerebrasAPIService
+
+Alternative fast-inference AI provider. Implements `IAIAPIService`.
+
+| Method | Purpose |
+|---|---|
+| `GetSimpleResponseAsync()` | Chat completion via Cerebras API |
+| `GetStreamedResponseAsync()` | Streaming chat via Cerebras API |
+
+Tracks token usage via logging.
+
+## ReplicateService
+
+Image generation and processing via Replicate.com API.
+
+| Method | Purpose |
+|---|---|
+| `GenerateImageAsync(string prompt)` | Image generation (Flux/Imagen model) |
+| `GenerateLogoAsync(string prompt)` | Specialized logo generation |
+| `RemoveBackgroundAsync(byte[] imageBytes)` | Background removal |
+| `AnalyzeImageAsync(byte[] imageBytes, string prompt)` | Image analysis/description |
+
+Uses `HttpClient` configured in `PluginNopStartup` with Replicate API token header.
+
+## OpenAIService
+
+Simple wrapper for quick single-prompt queries.
+
+| Method | Signature | Purpose |
+|---|---|---|
+| `AskAsync` | `(string prompt) -> Task` | Single prompt → response using GPT-4o-mini |
+
+## AICalculationService
+
+AI-powered business intelligence for the admin dashboard.
+
+| Method | Purpose |
+|---|---|
+| `GetWelcomeMessageAsync()` | Generates personalized admin dashboard briefing |
+
+**Aggregates:**
+- Recent order data (count, totals, status distribution)
+- Stock levels and discrepancies
+- Weather information
+- Flags inventory anomalies and processing issues
+
+Called from `CustomDashboardController`.
+
+## FileStorageService
+
+File upload/download service with deduplication and compression.
+
+| Method | Purpose |
+|---|---|
+| `SaveFileAsync(byte[], string fileName, ...)` | Save with hash calculation and optional compression |
+| `GetFileAsync(int fileId)` | Retrieve file with automatic decompression if compressed |
+| `SearchByFileNameAsync(string fileName)` | Search by original filename |
+| `SearchByHashAsync(string hash)` | Find by SHA256 hash |
+| `SearchByRawTextAsync(string text)` | Full-text search in extracted OCR content |
+| `CalculateFileHashAsync(byte[])` | SHA256 hash calculation |
+
+**Features:**
+- **Deduplication**: Checks SHA256 hash before saving. Returns existing file if duplicate found
+- **Compression**: Auto-compresses with GZip for non-compressed formats (excludes .jpg, .png, .gif, .zip, .gz, .pdf)
+- **RawText**: Stores OCR-extracted text for full-text search
+- **Metadata**: Tracks FileName, FileExtension, FileHash, FileSubPath, IsCompressed, Created/Modified
+
+## IFileStorageProvider / LocalStorageProvider
+
+Pluggable storage backend. Currently only `LocalStorageProvider` implemented.
+
+| Method | Purpose |
+|---|---|
+| `SaveFileAsync(byte[], string relativePath)` | Save to `wwwroot/uploads/{relativePath}` |
+| `GetFileAsync(string relativePath)` | Read file bytes |
+| `DeleteFileAsync(string relativePath)` | Delete file, cleanup empty parent directories |
+| `FileExistsAsync(string relativePath)` | Check file existence |
+
+Files organized by relative sub-paths. Auto-creates directories as needed.
+
+## PdfToImageService
+
+PDF rendering using PDFtoImage library (Pdfium native backend).
+
+| Method | Purpose |
+|---|---|
+| `ConvertPdfToJpgAsync(byte[] pdfBytes)` | Converts all PDF pages to JPG images |
+
+- 300 DPI rendering, white background
+- Returns `List` — one JPG per page
+- Pdfium native library bootstrapped on first use
+
+## Audio Transcription Flow
+
+1. `FruitBankAudioController` receives audio recording
+2. Calls `OpenAIApiService.TranscribeAudioAsync(audioData, language)`
+3. Sends to Whisper API endpoint (`/v1/audio/transcriptions`)
+4. Returns transcribed text
+5. Used for voice-based order entry in FruitBankHybridApp
diff --git a/Nop.Plugin.Misc.AIPlugin/docs/DATA_LAYER.md b/Nop.Plugin.Misc.AIPlugin/docs/DATA_LAYER.md
new file mode 100644
index 0000000..48938a5
--- /dev/null
+++ b/Nop.Plugin.Misc.AIPlugin/docs/DATA_LAYER.md
@@ -0,0 +1,174 @@
+# Data Layer
+
+> Part of `Nop.Plugin.Misc.FruitBankPlugin`. See `README.md` for project overview.
+> For entity model see `docs/DOMAIN_MODEL.md`.
+> For measurement workflows see `docs/MEASUREMENT.md`.
+
+## DbContext Classes
+
+### FruitBankDbContext
+
+Inherits `MgDbContextBase` (from `Mango.Nop.Data`). Main data context for orders, shipping, partners, and files.
+
+**Implements interfaces:** IOrderDtoDbSet, IOrderItemDtoDbSet, IPartnerDbSet, IShippingDbSet, IShippingDocumentDbSet, IShippingItemDbSet, IShippingItemPalletDbSet, IOrderItemPalletDbSet, IShippingDocumentToFilesDbSet, IFilesDbSet
+
+**DbSet properties:**
+
+| DbSet | Entity | Table |
+|---|---|---|
+| `OrderDtos` | OrderDto | Order |
+| `OrderItemDtos` | OrderItemDto | OrderItem |
+| `OrderItemPallets` | OrderItemPallet | fbOrderItemPallet |
+| `Partners` | Partner | fbPartner |
+| `Shippings` | Shipping | fbShipping |
+| `ShippingDocuments` | ShippingDocument | fbShippingDocument |
+| `ShippingItems` | ShippingItem | fbShippingItem |
+| `ShippingItemPallets` | ShippingItemPallet | fbShippingItemPallet |
+| `Files` | Files | fbFiles |
+| `ShippingDocumentToFiles` | ShippingDocumentToFiles | fbShippingDocumentToFiles |
+| `ProductDtos` | ProductDto | Product |
+| `GenericAttributeDtos` | GenericAttributeDto | GenericAttribute |
+| `StockQuantityHistoryDtos` | StockQuantityHistoryDto | StockQuantityHistory |
+| `Customers` | Customer | Customer |
+| `CustomerRoles` | CustomerRole | CustomerRole |
+
+**Key methods:**
+
+| Method | Purpose |
+|---|---|
+| `AddShippingItemAsync()` | Creates item, copies `IsMeasurable` from ProductDto |
+| `UpdateShippingItemAsync()` | Complex: updates measuring values, handles stock/weight changes, manages product transitions |
+| `SetupShippingItemMeasuringValues()` | Recalculates totals from child pallets |
+| `AddShippingItemPalletAsync()` / `UpdateShippingItemPalletAsync()` | Pallet CRUD with cascade to parent ShippingItem |
+| `StartMeasuringAsync()` | Sets MeasurementOwnerId GenericAttribute, updates order status |
+| `SetOrderStatusToCompleteAsync()` | Validates pallets, updates stock, sets RevisorId, publishes event |
+| `DeleteOrderItemConstraintsAsync()` | Cleanup when order items deleted |
+| `UpdateStockQuantityAndWeightAsync()` | Updates Product.StockQuantity + NetWeight, creates history entry |
+| `DeleteShippingSafeAsync()` | Transactional cascade delete of shipping + children |
+| `DeleteShippingDocumentSafeAsync()` | Transactional cascade delete of document + children |
+| `GetCustomersBySystemRoleName()` | LINQ query for role-based customer lookup |
+
+All state-changing operations wrapped in `TransactionSafeAsync()` for ACID compliance.
+
+### StockTakingDbContext
+
+Inherits `MgDbContextBase`. Dedicated context for inventory sessions.
+
+**DbSet properties:**
+
+| DbSet | Entity |
+|---|---|
+| `ProductDtos` | ProductDto |
+| `OrderItemDtos` | OrderItemDto |
+| `StockTakings` | StockTaking |
+| `StockTakingItems` | StockTakingItem |
+| `StockTakingItemPallets` | StockTakingItemPallet |
+| `StockQuantityHistories` | StockQuantityHistory |
+| `GenericAttributes` | GenericAttribute |
+
+**Key methods:**
+
+| Method | Purpose |
+|---|---|
+| `CloseStockTaking()` | Validates all measured, updates stock/weight per item, marks closed |
+| `AddStockTakingItemPallet()` / `UpdateStockTakingItemPallet()` | Pallet CRUD with measured values refresh |
+| `RefreshStockTakingItemMeasuredValuesFromPallets()` | Sums quantities/weights from pallets |
+
+## DbTable Repositories
+
+Each entity has a `*DbTable` class inheriting `MgDbTableBase` (from `Mango.Nop.Data`). Located in `Domains/DataLayer/`.
+
+### ShippingDbTable
+
+| Method | Purpose |
+|---|---|
+| `GetAll()` | All shippings ordered by ShippingDate |
+| `GetAll(bool loadRelations)` | Eager loads full graph: Documents → Items → Pallets + Pallet type + ProductDto + GenericAttributes |
+| `GetAllNotMeasured()` | Filters for incomplete/recent shipments |
+| `GetByIdAsync(bool loadRelations)` | Single with optional eager loading |
+| `OnUpdate()` | Hook: sets `MeasuredDate` when `IsAllMeasured` becomes true |
+
+### OrderDtoDbTable
+
+| Method | Purpose |
+|---|---|
+| `GetAll(bool loadRelations)` | Eager loads: GenericAttributes, Customer, OrderNotes, OrderItemDtos (with ProductDto, GenericAttributes, OrderItemPallets) |
+| `GetByIdAsync(bool loadRelations)` | Single order with optional relations |
+| `GetAllByOrderStatus()` | Status-filtered query |
+| `GetAllForMeasuring(DateTime fromDate)` | Unpaid, not cancelled, with DateOfReceipt >= fromDate |
+| `GetAllByProductId()` / `GetAllByProductIds()` | Product-filtered queries |
+| `GetAllByIds()` | Batch retrieval |
+
+### Other DbTable classes
+
+| Class | Entity | Key behaviors |
+|---|---|---|
+| `ProductDtoDbTable` | ProductDto | GetAll with GenericAttributes |
+| `PartnerDbTable` | Partner | Standard CRUD |
+| `ShippingDocumentDbTable` | ShippingDocument | GetAll with items, files, partner |
+| `ShippingItemDbTable` | ShippingItem | GetAll with pallets |
+| `ShippingItemPalletDbTable` | ShippingItemPallet | Standard CRUD |
+| `OrderItemDtoDbTable` | OrderItemDto | GetAll with pallets, product |
+| `OrderItemPalletDbTable` | OrderItemPallet | Standard CRUD |
+| `FilesDbTable` | Files | Standard CRUD |
+| `ShippingDocumentToFilesDbTable` | ShippingDocumentToFiles | Standard CRUD |
+| `StockTakingDbTable` | StockTaking | GetAll with items, pallets |
+| `StockTakingItemDbTable` | StockTakingItem | GetAll with pallets |
+| `StockTakingItemPalletDbTable` | StockTakingItemPallet | Standard CRUD |
+| `StockQuantityHistoryDtoDbTable` | StockQuantityHistoryDto | Filtered by date range |
+
+## Repository Interfaces
+
+Located in `Domains/DataLayer/Interfaces/`. Each interface (e.g., `IShippingDbSet`, `IOrderDtoDbSet`) declares the DbSet property that the DbContext must implement. This allows DbTable classes to accept any context that provides the required DbSet.
+
+## Entity Mapping
+
+- `Mapping/Builders/PluginBuilder.cs` — EF Core Fluent API entity configuration for plugin-owned tables (fb* prefix)
+- `Mapping/NameCompatibility.cs` — Table name mapping for nopCommerce compatibility
+- `Migrations/SchemaMigration.cs` — FluentMigrator schema setup for `CustomTable` base entity
+
+## FruitBankEventConsumer
+
+Located in `Domains/EventConsumers/`. Extends `MgEventConsumerBase` (from `Mango.Nop.Services`). Handles entity lifecycle events for cascading updates.
+
+**Product events:**
+
+| Event | Action |
+|---|---|
+| `EntityInsertedEvent` | Saves custom attributes (IsMeasurable, NetWeight, Tare, AverageWeight, AverageWeightTreshold, IncomingQuantity) |
+| `EntityUpdatedEvent` | Updates custom attributes, syncs IsMeasurable/Tare changes to existing ShippingItems |
+
+**Shipping cascade events:**
+
+| Event | Action |
+|---|---|
+| `EntityDeletedEvent` | Refreshes parent ShippingItem measuring values |
+| `EntityInsertedEvent/UpdatedEvent` | Rechecks ShippingDocument.IsAllMeasured |
+| `EntityInsertedEvent/UpdatedEvent` | Rechecks Shipping.IsAllMeasured |
+| `EntityDeletedEvent` | Cascade deletes child documents |
+| `EntityDeletedEvent` | Cascade deletes child items |
+| `EntityDeletedEvent` | Cascade deletes child pallets |
+
+**Order events:**
+
+| Event | Action |
+|---|---|
+| `EntityDeletedEvent` | Cleanup via `MeasurementService.DeleteOrderItemConstraintsAsync()` |
+| `EntityInsertedEvent/UpdatedEvent` | Post-process via `MeasurementService.OrderItemInsertedOrUpdatedPostProcess()` |
+
+## Eager Loading Pattern
+
+DbTable `GetAll(bool loadRelations = true)` uses `LoadWith()` chains to eagerly load entity graphs. Example for OrderDtoDbTable:
+
+```
+OrderDto
+ → GenericAttributes
+ → Customer
+ → OrderNotes
+ → OrderItemDtos
+ → ProductDto → GenericAttributes
+ → GenericAttributes
+ → OrderItemPallets
+```
+
+This avoids N+1 queries when transferring full entity graphs to FruitBankHybridApp via SignalR.
diff --git a/Nop.Plugin.Misc.AIPlugin/docs/DOMAIN_MODEL.md b/Nop.Plugin.Misc.AIPlugin/docs/DOMAIN_MODEL.md
new file mode 100644
index 0000000..46ef545
--- /dev/null
+++ b/Nop.Plugin.Misc.AIPlugin/docs/DOMAIN_MODEL.md
@@ -0,0 +1,116 @@
+# Domain Model — Behavioral Documentation
+
+> Part of `Nop.Plugin.Misc.FruitBankPlugin`. See `README.md` for project overview.
+> For entity definitions, properties, and relationships see `docs/SCHEMA.md` (authoritative TOON schema).
+> For measurement workflows see `docs/MEASUREMENT.md`.
+> For data layer details see `docs/DATA_LAYER.md`.
+> For core measurement system rules and common domain traps, see: `../../../../../FruitBankHybridApp/FruitBank.Common/docs/GLOSSARY.md`
+
+This document explains **how the domain model behaves** in the plugin. For **what the entities are** (properties, types, relationships), see `docs/SCHEMA.md`.
+
+## Weight Formula
+
+All three pallet types (ShippingItemPallet, OrderItemPallet, StockTakingItemPallet) use the same formula:
+
+```
+NetWeight = GrossWeight - PalletWeight - (TrayQuantity * TareWeight)
+```
+
+| Field | Meaning |
+|---|---|
+| `GrossWeight` | Scale reading (total weight). 0.0 if product is not measurable |
+| `PalletWeight` | Weight of the physical pallet. 0.0 if goods arrive without pallet |
+| `TareWeight` | Weight of one tray/crate (per unit) |
+| `TrayQuantity` | Number of trays/crates. Always recorded, even for non-measurable products |
+| `NetWeight` | Computed, read-only, not-mapped (setter exists for shared interface but throws Exception) |
+
+## MeasuringStatus Lifecycle
+
+```
+NotStarted (0) → Started (10) → Finnished (20) → Audited (30)
+ [no record] [record created] [IsMeasured=true] [RevisorId > 0]
+```
+
+- **NotStarted**: No pallet record exists yet
+- **Started**: Pallet record created (Id > 0) but `IsMeasured = false`
+- **Finnished**: `IsMeasured = true` — weights recorded
+- **Audited**: `RevisorId > 0` — quality auditor confirmed (OrderItemPallet only)
+
+## IsMeasurable Flag
+
+`ProductDto.IsMeasurable` is the master toggle (stored as GenericAttribute). When `false`:
+- Weight validation is bypassed
+- One pallet record is still created with `GrossWeight = 0.0`
+- Only `TrayQuantity` is recorded
+- Product is not included in average weight checks
+
+## GenericAttribute Keys
+
+Custom product/order properties are stored via nopCommerce's GenericAttribute table. The DTO classes expose these as computed, not-mapped properties.
+
+### Product attributes (saved by FruitBankEventConsumer)
+
+| Key | Type | Purpose |
+|---|---|---|
+| `IsMeasurable` | bool | Master flag for weight validation |
+| `NetWeight` | double | Product net weight |
+| `Tare` | double | Tare weight per tray |
+| `AverageWeight` | double | Expected average weight per tray |
+| `AverageWeightTreshold` | double | Max allowed deviation % from AverageWeight |
+| `IncomingQuantity` | int | Incoming stock not yet received |
+
+### Order attributes
+
+| Key | Type | Purpose |
+|---|---|---|
+| `DateOfReceipt` | DateTime | Scheduled delivery date |
+| `MeasurementOwnerId` | int | User who started measuring |
+| `RevisorId` | int | Quality auditor user ID |
+
+## Entity Hierarchy Overview
+
+### Inbound Delivery (Shipping)
+
+```
+Shipping (fbShipping) ← truck arrival
+ └─ ShippingDocument (fbShippingDocument) ← supplier delivery note [1:N]
+ ├─ ShippingItem (fbShippingItem) ← product line [1:N]
+ │ └─ ShippingItemPallet ← measurement event [1:N]
+ ├─ ShippingDocumentToFiles ← uploaded PDFs [1:N]
+ │ └─ Files (fbFiles) [N:1]
+ └─ Partner (fbPartner) ← supplier [N:1]
+```
+
+### Outbound Order
+
+```
+OrderDto (Order) ← nopCommerce order
+ ├─ OrderItemDto (OrderItem) ← line item [1:N]
+ │ ├─ OrderItemPallet (fbOrderItemPallet) ← measurement record [1:N]
+ │ └─ ProductDto (Product) [N:1]
+ ├─ Customer [N:1]
+ └─ GenericAttributeDto ← custom attributes [1:N]
+```
+
+### Inventory (StockTaking)
+
+```
+StockTaking (fbStockTaking) ← inventory session
+ └─ StockTakingItem (fbStockTakingItem) ← product line [1:N]
+ ├─ StockTakingItemPallet ← measurement record [1:N]
+ └─ ProductDto (Product) [N:1]
+```
+
+## FullProcessModel
+
+Container for bulk data sync between server and FruitBankHybridApp via SignalR:
+
+| Property | Type | Purpose |
+|---|---|---|
+| `Orders` | `List` | All active orders with full graph |
+| `Shippings` | `List` | All active shipments with full graph |
+| `StockTakings` | `List` | All active inventory sessions |
+
+## Note on "Pallet" Naming
+
+The term "Pallet" in `OrderItemPallet`, `ShippingItemPallet`, and `StockTakingItemPallet` is a legacy naming convention. These are **general measurement records** that are always created for every item, regardless of whether goods arrive on a physical pallet. For non-measurable products, weight fields are 0.0 and only `TrayQuantity` is tracked.
diff --git a/Nop.Plugin.Misc.AIPlugin/docs/MEASUREMENT.md b/Nop.Plugin.Misc.AIPlugin/docs/MEASUREMENT.md
new file mode 100644
index 0000000..8a27ce3
--- /dev/null
+++ b/Nop.Plugin.Misc.AIPlugin/docs/MEASUREMENT.md
@@ -0,0 +1,175 @@
+# Measurement Workflows
+
+> Part of `Nop.Plugin.Misc.FruitBankPlugin`. See `README.md` for project overview.
+> For entity model see `docs/DOMAIN_MODEL.md`.
+> For SignalR endpoints see `docs/SIGNALR_ENDPOINTS.md`.
+> For core measurement system rules and common domain traps, see: `../../../../../FruitBankHybridApp/FruitBank.Common/docs/GLOSSARY.md`
+
+Three parallel measurement workflows share the same weight formula and `MeasuringStatus` lifecycle.
+
+## MeasuringStatus Lifecycle
+
+```
+NotStarted (0) → Started (10) → Finnished (20) → Audited (30)
+ [no record] [record created] [IsMeasured=true] [RevisorId > 0]
+```
+
+- **NotStarted**: No pallet record exists yet
+- **Started**: Pallet record created (Id > 0) but `IsMeasured = false`
+- **Finnished**: `IsMeasured = true` — weights recorded
+- **Audited**: `RevisorId > 0` — quality auditor confirmed (OrderItemPallet only)
+
+## IsMeasurable Flag
+
+`ProductDto.IsMeasurable` is the master toggle (stored as GenericAttribute). When `false`:
+- Weight validation is bypassed
+- One pallet record is still created with `GrossWeight = 0.0`
+- Only `TrayQuantity` is recorded (for tare-weight calculations)
+- Product is not included in average weight checks
+
+## Workflow 1: Inbound Shipping
+
+Supplier truck arrives at the warehouse. Goods are weighed and compared to the delivery document.
+
+```
+Shipping (truck)
+ → ShippingDocument (supplier delivery note)
+ → ShippingItem (product line)
+ → ShippingItemPallet (individual measurement)
+```
+
+### Flow
+
+1. **Create Shipping** — truck registration (LicencePlate, ShippingDate, CargoCompany)
+2. **Add ShippingDocument** — link to Partner (supplier), upload PDF delivery note
+3. **Add ShippingItem** — product line from document. `IsMeasurable` copied from `ProductDto.IsMeasurable`
+4. **Weigh pallets** — `FruitBankDataController.AddOrUpdateMeasuredShippingItemPallet()`
+ - Each pallet: GrossWeight, PalletWeight, TareWeight, TrayQuantity
+ - NetWeight computed: `GrossWeight - PalletWeight - (TrayQuantity * TareWeight)`
+5. **Cascade update** — after each pallet measurement:
+ - `ShippingItem`: MeasuredGrossWeight, MeasuredNetWeight, MeasuredQuantity recalculated from pallets
+ - `ShippingDocument.IsAllMeasured` = all items measured
+ - `Shipping.IsAllMeasured` = all documents measured
+ - Product `AverageWeight` updated from measured data
+
+### Event Cascade (FruitBankEventConsumer)
+
+```
+ShippingItemPallet deleted → ShippingItem measuring values refreshed
+ShippingItem inserted/updated → ShippingDocument.IsAllMeasured rechecked
+ShippingDocument inserted/updated → Shipping.IsAllMeasured rechecked
+Shipping deleted → cascade delete ShippingDocuments
+ShippingDocument deleted → cascade delete ShippingItems
+ShippingItem deleted → cascade delete ShippingItemPallets
+```
+
+### ShippingItem: Declared vs Measured
+
+| Declared (from document) | Measured (warehouse) | Discrepancy tracked |
+|---|---|---|
+| `QuantityOnDocument` | `MeasuredQuantity` | Yes |
+| `GrossWeightOnDocument` | `MeasuredGrossWeight` | Yes |
+| `NetWeightOnDocument` | `MeasuredNetWeight` | Yes |
+
+## Workflow 2: Outbound Order
+
+Customer order is prepared. Items are weighed before dispatch.
+
+```
+OrderDto (order)
+ → OrderItemDto (line item)
+ → OrderItemPallet (measurement record)
+```
+
+### Flow
+
+1. **Start Measuring** — `CustomOrderSignalREndpoint.StartMeasuring(orderId, userId)`
+ - Sets `MeasurementOwnerId` via GenericAttribute
+ - Order status set to Processing, Payment to Pending
+ - Broadcasts `SendOrderChanged`
+2. **Weigh pallets** — `CustomOrderSignalREndpoint.AddOrUpdateMeasuredOrderItemPallet()`
+ - Same weight formula as shipping
+ - Triggers price recalculation after each pallet
+ - Broadcasts `SendOrderItemPalletChanged`
+3. **Complete order** — `CustomOrderSignalREndpoint.SetOrderStatusToComplete(orderId, revisorId)`
+ - Validates: all order items must have pallets
+ - Recalculates final prices via `CustomPriceCalculationService`
+ - Updates product stock quantities and weights
+ - Sets `RevisorId` on each OrderItemPallet → MeasuringStatus becomes Audited
+ - Order status → Complete
+ - Publishes `OrderPaidEvent` for nopCommerce integration
+
+### Average Weight Validation
+
+After measuring, `OrderItemDto.AverageWeightIsValid` checks:
+
+```
+deviation = |ProductDto.AverageWeight - OrderItemDto.AverageWeight|
+percentage = (deviation / ProductDto.AverageWeight) * 100
+valid = percentage < ProductDto.AverageWeightTreshold
+```
+
+`OrderDto.IsAllOrderItemAvgWeightValid` = all items pass this check.
+
+### PendingMeasurementCheckoutFilter
+
+ASP.NET Core action filter on the Checkout `ConfirmOrder` action.
+
+| Step | Action |
+|---|---|
+| Intercept | `ConfirmOrder` POST in CheckoutController |
+| Check | `OrderMeasurementService.IsPendingMeasurementAsync()` |
+| If pending | Set OrderStatus=Processing, PaymentStatus=Pending, redirect to PendingMeasurementWarning |
+| If not pending | Allow normal checkout flow |
+
+## Workflow 3: StockTaking
+
+Inventory session compares logical stock with physical count.
+
+```
+StockTaking (session)
+ → StockTakingItem (product line)
+ → StockTakingItemPallet (measurement record)
+```
+
+### Flow
+
+1. **Create session** — `StockSignalREndpointServer.AddStockTaking()`
+ - Auto-populates `StockTakingItem` for each product
+ - Captures `OriginalStockQuantity` (current stock) and `InProcessOrdersQuantity` (reserved in pending orders)
+ - `TotalOriginalQuantity = OriginalStockQuantity + InProcessOrdersQuantity`
+2. **Weigh items** — `StockSignalREndpointServer.AddOrUpdateMeasuredStockTakingItemPallet()`
+ - Same weight formula
+ - Refreshes `MeasuredStockQuantity` and `MeasuredNetWeight` from pallets
+3. **Close session** — `StockSignalREndpointServer.CloseStockTaking()`
+ - Validates: all items must be measured
+ - For each item: `QuantityDiff = MeasuredStockQuantity - TotalOriginalQuantity`
+ - Updates `Product.StockQuantity` with the diff
+ - Updates `Product.NetWeight` with measured weight
+ - Creates stock quantity history entries
+ - Marks session as closed (`IsClosed = true`)
+
+## CustomPriceCalculationService
+
+Extends nopCommerce's `PriceCalculationService`. Overrides order item pricing based on measurement data.
+
+| Scenario | Price calculation |
+|---|---|
+| Measurable product | `FinalPrice = NetWeight * UnitPriceExclTax` (weight-based) |
+| Non-measurable product | `FinalPrice = Quantity * UnitPriceExclTax` (standard quantity-based) |
+
+**Methods:**
+
+| Method | Purpose |
+|---|---|
+| `CalculateOrderItemFinalPrice(OrderItemDto)` | Single item price calculation |
+| `CheckAndUpdateOrderItemFinalPricesAsync(int orderId)` | Recalculates all items in an order |
+| `CheckAndUpdateOrderTotalPrice(int orderId)` | Updates order total from item sum |
+
+## Stock Update Flow
+
+When an order is completed or a stocktaking session closes, `FruitBankDbContext.UpdateStockQuantityAndWeightAsync()`:
+
+1. Updates `Product.StockQuantity` (add/subtract delta)
+2. Updates `Product.NetWeight` via GenericAttribute
+3. Creates `StockQuantityHistory` entry with extension record for audit trail
diff --git a/Nop.Plugin.Misc.AIPlugin/docs/SCHEMA.md b/Nop.Plugin.Misc.AIPlugin/docs/SCHEMA.md
new file mode 100644
index 0000000..b18dc77
--- /dev/null
+++ b/Nop.Plugin.Misc.AIPlugin/docs/SCHEMA.md
@@ -0,0 +1,637 @@
+# Domain Model Schema (Toon Format)
+
+> Part of `Nop.Plugin.Misc.FruitBankPlugin`. See `README.md` for project overview.
+> Full domain model in Toon (Token-Oriented Object Notation) format — see `AyCode.Core/Serializers/Toons/README.md` (in AyCode.Core solution).
+> This is the authoritative schema for entities, DTOs, and enums in the FruitBank domain.
+> For behavioral documentation (workflows, lifecycles, event cascades) see `docs/DOMAIN_MODEL.md`.
+
+@meta {
+ version = "1.0"
+ format = "toon"
+ source-code-language = "C#"
+ context = "This is a nopCommerce plugin developed for FruitBank, a fruit and vegetable wholesaler. The plugin manages supplier inbound delivery (receiving), warehouse weighing (net/gross/pallet/tare weights), and inventory stocktaking. The business logic is centered around FruitBank's requirement for precise physical measurement and quantity tracking."
+ types = ["OrderStatus", "ShippingStatus", "PaymentStatus", "GenericAttributeDto", "MeasuringStatus", "VatNumberStatus", "TaxDisplayType", "OrderNote", "DocumentType", "Files", "Pallet", "ProductDto", "Customer", "FullProcessModel", "OrderDto", "OrderItemDto", "OrderItemPallet", "Partner", "Shipping", "ShippingDocument", "ShippingDocumentToFiles", "ShippingItem", "ShippingItemPallet", "StockTaking", "StockTakingItem", "StockTakingItemPallet"]
+}
+
+@types {
+ OrderStatus: enum
+ underlying-type: "int"
+ default-value: 10
+ values:
+ Pending = 10
+ Processing = 20
+ Complete = 30
+ Cancelled = 40
+
+ ShippingStatus: enum
+ underlying-type: "int"
+ default-value: 10
+ values:
+ ShippingNotRequired = 10
+ NotYetShipped = 20
+ PartiallyShipped = 25
+ Shipped = 30
+ Delivered = 40
+
+ PaymentStatus: enum
+ underlying-type: "int"
+ default-value: 10
+ values:
+ Pending = 10
+ Authorized = 20
+ Paid = 30
+ PartiallyRefunded = 35
+ Refunded = 40
+ Voided = 50
+
+ GenericAttributeDto: "Data transfer object for GenericAttribute"
+ table-name: "GenericAttribute"
+ related-type: "dto-of GenericAttribute"
+ CreatedOrUpdatedDateUTC: DateTime?
+ EntityId: int
+ constraints: "polymorphic-fk(KeyGroup)"
+ Key: string
+ KeyGroup: string
+ StoreId: int
+ Value: string
+ purpose: "Raw string representation of the Key's value"
+ Id: int
+ purpose: "Primary key / unique identification"
+ primary-key: true
+
+ MeasuringStatus: enum
+ underlying-type: "int"
+ default-value: 0
+ values:
+ NotStarted = 0
+ Started = 10
+ Finnished = 20
+ Audited = 30
+
+ VatNumberStatus: enum
+ underlying-type: "int"
+ default-value: 0
+ values:
+ Unknown = 0
+ Empty = 10
+ Valid = 20
+ Invalid = 30
+
+ TaxDisplayType: enum
+ underlying-type: "int"
+ default-value: 0
+ values:
+ IncludingTax = 0
+ ExcludingTax = 10
+
+ OrderNote: "NopCommerce order note entity"
+ table-name: "OrderNote"
+ CreatedOnUtc: DateTime
+ DisplayToCustomer: bool
+ DownloadId: int
+ Note: string
+ OrderId: int
+ description: "Foreign key to parent Order"
+ Id: int
+ purpose: "Primary key / unique identification"
+ primary-key: true
+
+ DocumentType: enum
+ underlying-type: "int"
+ default-value: 0
+ values:
+ NotSet = 0
+ Unknown = 5
+ ShippingDocument = 10
+ OrderConfirmation = 15
+ Invoice = 20
+
+ Files: "Uploaded file with extracted text content"
+ table-name: "fbFiles"
+ purpose: "A centralized repository for all uploaded binary content and metadata, featuring a 'RawText' field that stores OCR-extracted information for full-text search and automated data validation across the system"
+ Created: DateTime
+ FileExtension: string
+ FileHash: string
+ FileName: string
+ FileSubPath: string
+ IsCompressed: bool
+ purpose: "Status flag"
+ Modified: DateTime
+ RawText: string
+ Id: int
+ purpose: "Primary key / unique identification"
+ primary-key: true
+
+ Pallet: "Pallet type definition with size and weight"
+ table-name: "fbPallet"
+ Created: DateTime
+ Modified: DateTime
+ Name: string
+ Size: string
+ Weight: double?
+ Id: int
+ purpose: "Primary key / unique identification"
+ primary-key: true
+
+ ProductDto: "Product data with measurements and generic attributes"
+ table-name: "Product"
+ related-type: "dto-of Product"
+ AvailableQuantity: int
+ business-logic: "get => StockQuantity + IncomingQuantity"
+ constraints: "readonly, not-mapped"
+ AverageWeight: double
+ business-logic: "get => GenericAttributes.GetValueOrDefault('AverageWeight')"
+ constraints: "readonly, not-mapped"
+ AverageWeightTreshold: double
+ business-logic: "get => GenericAttributes.GetValueOrDefault('AverageWeightTreshold')"
+ constraints: "readonly, not-mapped"
+ GenericAttributes: List
+ navigation: "one-to-many"
+ IncomingQuantity: int
+ business-logic: "get => GenericAttributes.GetValueOrDefault('IncomingQuantity')"
+ constraints: "not-mapped"
+ IsMeasurable: bool
+ purpose: "Master flag: if false, the system bypasses weight validation but still creates one Measurement Record (PalletItem) with TrayQuantity."
+ business-logic: "get => GenericAttributes.GetValueOrDefault('IsMeasurable')"
+ constraints: "not-mapped"
+ NetWeight: double
+ business-logic: "get => GenericAttributes.GetValueOrDefault('NetWeight')"
+ constraints: "not-mapped"
+ Tare: double
+ business-logic: "get => GenericAttributes.GetValueOrDefault('Tare')"
+ constraints: "not-mapped"
+ Deleted: bool
+ FullDescription: string
+ Height: decimal
+ Length: decimal
+ LimitedToStores: bool
+ Name: string
+ ParentGroupedProductId: int
+ Price: decimal
+ ProductCost: decimal
+ ProductTypeId: int
+ ShortDescription: string
+ StockQuantity: int
+ SubjectToAcl: bool
+ WarehouseId: int
+ Weight: decimal
+ Width: decimal
+ Id: int
+ purpose: "Primary key / unique identification"
+ primary-key: true
+
+ Customer: "NopCommerce customer entity"
+ table-name: "Customer"
+ Active: bool
+ AdminComment: string
+ AffiliateId: int
+ BillingAddressId: int?
+ CannotLoginUntilDateUtc: DateTime?
+ City: string
+ Company: string
+ CountryId: int
+ County: string
+ CreatedOnUtc: DateTime
+ CurrencyId: int?
+ CustomCustomerAttributesXML: string
+ CustomerGuid: Guid
+ DateOfBirth: DateTime?
+ Deleted: bool
+ Email: string
+ constraints: "email-format"
+ EmailToRevalidate: string
+ constraints: "email-format"
+ FailedLoginAttempts: int
+ Fax: string
+ FirstName: string
+ Gender: string
+ HasShoppingCartItems: bool
+ IsSystemAccount: bool
+ purpose: "Status flag"
+ IsTaxExempt: bool
+ purpose: "Status flag"
+ LanguageId: int?
+ constraints: "range: 0-150"
+ LastActivityDateUtc: DateTime
+ LastIpAddress: string
+ LastLoginDateUtc: DateTime?
+ LastName: string
+ MustChangePassword: bool
+ Phone: string
+ RegisteredInStoreId: int
+ RequireReLogin: bool
+ ShippingAddressId: int?
+ StateProvinceId: int
+ StreetAddress: string
+ StreetAddress2: string
+ SystemName: string
+ TaxDisplayType: TaxDisplayType?
+ TaxDisplayTypeId: int?
+ TimeZoneId: string
+ Username: string
+ VatNumber: string
+ VatNumberStatus: VatNumberStatus
+ VatNumberStatusId: int
+ VendorId: int
+ ZipPostalCode: string
+ Id: int
+ purpose: "Primary key / unique identification"
+ primary-key: true
+
+ FullProcessModel: "Object of type FullProcessModel"
+ table-name: "FullProcessModel"
+ purpose: "Container model for Shipping, Order"
+ Orders: List
+ navigation: "one-to-many"
+ Shippings: List
+ navigation: "one-to-many"
+ StockTakings: List
+ navigation: "one-to-many"
+
+ OrderDto: "Data transfer object for Order"
+ table-name: "Order"
+ related-type: "dto-of Order"
+ DateOfReceipt: DateTime?
+ business-logic: "get => GenericAttributes.GetValueOrNull('DateOfReceipt')"
+ constraints: "readonly, not-mapped"
+ DateOfReceiptOrCreated: DateTime
+ constraints: "readonly, not-mapped"
+ GenericAttributes: List
+ navigation: "one-to-many"
+ IsAllOrderItemAudited: bool
+ purpose: "Status flag"
+ business-logic: "get => OrderItemDtos.Count > 0 && OrderItemDtos.All(oi => oi.IsAudited)"
+ constraints: "readonly, not-mapped"
+ IsAllOrderItemAvgWeightValid: bool
+ purpose: "Status flag"
+ business-logic: "get => OrderItemDtos.All(oi => oi.AverageWeightIsValid)"
+ constraints: "readonly, not-mapped"
+ IsComplete: bool
+ purpose: "Status flag"
+ business-logic: "get => OrderStatus == OrderStatus.Complete"
+ constraints: "readonly, not-mapped"
+ IsMeasurable: bool
+ purpose: "Status flag"
+ business-logic: "get => OrderItemDtos.Any(oi => oi.IsMeasurable)"
+ constraints: "readonly, not-mapped"
+ IsMeasured: bool
+ purpose: "Status flag"
+ business-logic: "get => Id > 0 && OrderItemDtos.Count > 0 && OrderItemDtos.All(x => x.IsMeasured)"
+ constraints: "readonly, not-mapped"
+ MeasurementOwnerId: int
+ business-logic: "get => GenericAttributes.GetValueOrDefault('MeasurementOwnerId', 0)"
+ constraints: "readonly, not-mapped"
+ MeasuringStatus: MeasuringStatus
+ constraints: "readonly, not-mapped"
+ RevisorId: int
+ business-logic: "get => GenericAttributes.GetValueOrDefault('RevisorId', 0)"
+ constraints: "readonly, not-mapped"
+ TimeOfReceiptText: string
+ constraints: "readonly, not-mapped"
+ CreatedOnUtc: DateTime
+ CustomOrderNumber: string
+ CustomValuesXml: string
+ Customer: Customer
+ foreign-key: "CustomerId"
+ navigation: "many-to-one"
+ CustomerId: int
+ Deleted: bool
+ OrderDiscount: decimal
+ OrderGuid: Guid
+ OrderItemDtos: List
+ other-key: "OrderId"
+ navigation: "one-to-many"
+ inverse-property: "OrderDto"
+ OrderNotes: List
+ other-key: "OrderId"
+ navigation: "one-to-many"
+ OrderStatus: OrderStatus
+ purpose: "Enum wrapper"
+ business-logic: "get, set => OrderStatusId"
+ OrderStatusId: int
+ OrderTotal: decimal
+ PaidDateUtc: DateTime?
+ PaymentStatus: PaymentStatus
+ purpose: "Enum wrapper"
+ business-logic: "get, set => PaymentStatusId"
+ PaymentStatusId: int
+ ShippingMethod: string
+ ShippingStatus: ShippingStatus
+ purpose: "Enum wrapper"
+ business-logic: "get, set => ShippingStatusId"
+ ShippingStatusId: int
+ StoreId: int
+ Id: int
+ purpose: "Primary key / unique identification"
+ primary-key: true
+
+ OrderItemDto: "Order item with measurements, pallets, and validation"
+ table-name: "OrderItem"
+ related-type: "dto-of OrderItem"
+ AverageWeight: double
+ business-logic: "get => IsMeasurable && OrderItemPallets.Count > 0 ? double.Round(OrderItemPallets.Sum(oip => oip.AverageWeight) / OrderItemPallets.Count, 1) : 0d"
+ constraints: "readonly, not-mapped"
+ AverageWeightDifference: double
+ business-logic: "get => IsMeasurable ? double.Round(ProductDto!.AverageWeight - AverageWeight, 1) : 0"
+ constraints: "readonly, not-mapped"
+ AverageWeightIsValid: bool
+ business-logic: "get => !IsMeasurable || (ProductDto!.AverageWeight > 0 && ((AverageWeightDifference / ProductDto!.AverageWeight) * 100) < ProductDto!.AverageWeightTreshold)"
+ constraints: "readonly, not-mapped"
+ GenericAttributes: List
+ navigation: "one-to-many"
+ GrossWeight: double
+ business-logic: "get => double.Round(OrderItemPallets.Sum(x => x.NetWeight), 1)"
+ constraints: "not-mapped"
+ IsAudited: bool
+ purpose: "Status flag"
+ business-logic: "get => OrderItemPallets.Count > 0 && OrderItemPallets.All(oip => oip.IsAudited)"
+ constraints: "readonly, not-mapped"
+ IsMeasurable: bool
+ purpose: "Status flag"
+ business-logic: "get => ProductDto!.IsMeasurable"
+ constraints: "not-mapped"
+ IsMeasured: bool
+ purpose: "Status flag"
+ business-logic: "get => IsMeasuredAndValid()"
+ constraints: "not-mapped"
+ MeasuringStatus: MeasuringStatus
+ business-logic: "get => complex conditional logic based on IsAudited, IsMeasured, and OrderItemPallets status"
+ constraints: "readonly, not-mapped"
+ NetWeight: double
+ business-logic: "get => double.Round(OrderItemPallets.Sum(x => x.NetWeight), 1)"
+ constraints: "not-mapped"
+ OrderDto: OrderDto
+ foreign-key: "OrderId"
+ navigation: "many-to-one"
+ inverse-property: "OrderItemDtos"
+ OrderItemPallets: List
+ other-key: "OrderItemId"
+ navigation: "one-to-many"
+ inverse-property: "OrderItemDto"
+ TrayQuantity: int
+ business-logic: "get => OrderItemPallets.Sum(x => x.TrayQuantity)"
+ constraints: "not-mapped"
+ AttributesXml: string
+ ItemWeight: decimal?
+ OrderId: int
+ OrderItemGuid: Guid
+ PriceExclTax: decimal
+ PriceInclTax: decimal
+ ProductDto: ProductDto
+ foreign-key: "ProductId"
+ navigation: "many-to-one"
+ ProductId: int
+ ProductName: string
+ business-logic: "get => ProductDto?.Name ?? 'ProductDto is null!!!'"
+ constraints: "readonly"
+ Quantity: int
+ UnitPriceExclTax: decimal
+ UnitPriceInclTax: decimal
+ Id: int
+ purpose: "Primary key / unique identification"
+ primary-key: true
+
+ OrderItemPallet: "Pallet measurements for order items with audit tracking"
+ table-name: "fbOrderItemPallet"
+ purpose: "A measurement record for outgoing goods. NOTE: Despite the 'Pallet' name, this is a general measurement record that is ALWAYS created for every item."
+ AverageWeight: double
+ business-logic: "get => double.Round(NetWeight / TrayQuantity, 1)"
+ constraints: "readonly, not-mapped"
+ IsAudited: bool
+ purpose: "Status flag"
+ business-logic: "get => RevisorId > 0"
+ constraints: "readonly, not-mapped"
+ MeasuringStatus: MeasuringStatus
+ business-logic: "get => IsAudited ? MeasuringStatus.Audited : base.MeasuringStatus"
+ constraints: "readonly, not-mapped"
+ OrderItemDto: OrderItemDto
+ foreign-key: "OrderItemId"
+ navigation: "many-to-one"
+ inverse-property: "OrderItemPallets"
+ OrderItemId: int
+ RevisorId: int
+ purpose: "User/Customer ID of the quality auditor"
+ Created: DateTime
+ CreatorId: int?
+ GrossWeight: double
+ IsMeasured: bool
+ Modified: DateTime
+ ModifierId: int?
+ NetWeight: double
+ business-logic: "get => GrossWeight - PalletWeight - (TrayQuantity * TareWeight)"
+ constraints: "readonly, not-mapped"
+ PalletWeight: double
+ TareWeight: double
+ TrayQuantity: int
+ Id: int
+ primary-key: true
+
+ Partner: "Business partner with address and tax information"
+ table-name: "fbPartner"
+ purpose: "External supplier providing goods"
+ CertificationNumber: string
+ City: string
+ Country: string
+ County: string
+ Created: DateTime
+ Modified: DateTime
+ Name: string
+ PostalCode: string
+ ShippingDocuments: List
+ navigation: "one-to-many"
+ State: string
+ Street: string
+ TaxId: string
+ Id: int
+ primary-key: true
+
+ Shipping: "Inbound delivery event at warehouse"
+ table-name: "fbShipping"
+ CargoCompany: string
+ Comment: string
+ Created: DateTime
+ IsAllMeasured: bool
+ LicencePlate: string
+ MeasuredDate: DateTime?
+ Modified: DateTime
+ ShippingDate: DateTime
+ ShippingDocuments: List
+ navigation: "one-to-many"
+ Id: int
+ primary-key: true
+
+ ShippingDocument: "Supplier delivery note or invoice"
+ table-name: "fbShippingDocument"
+ Comment: string
+ Country: string
+ Created: DateTime
+ DocumentIdNumber: string
+ IsAllMeasured: bool
+ Modified: DateTime
+ Partner: Partner
+ foreign-key: "PartnerId"
+ navigation: "many-to-one"
+ PartnerId: int
+ PdfFileName: string
+ Shipping: Shipping
+ foreign-key: "ShippingId"
+ navigation: "many-to-one"
+ ShippingDate: DateTime
+ ShippingDocumentToFiles: List
+ navigation: "one-to-many"
+ ShippingId: int?
+ ShippingItems: List
+ navigation: "one-to-many"
+ TotalPallets: int
+ Id: int
+ primary-key: true
+
+ ShippingDocumentToFiles: "Links documents to files with type"
+ table-name: "fbShippingDocumentToFiles"
+ Created: DateTime
+ DocumentType: DocumentType
+ DocumentTypeId: int
+ FilesId: int
+ Modified: DateTime
+ ShippingDocument: ShippingDocument
+ foreign-key: "ShippingDocumentId"
+ navigation: "many-to-one"
+ ShippingDocumentFile: Files
+ foreign-key: "FilesId"
+ navigation: "many-to-one"
+ ShippingDocumentId: int
+ Id: int
+ primary-key: true
+
+ ShippingItem: "Product line on shipping document"
+ table-name: "fbShippingItem"
+ Created: DateTime
+ GrossWeightOnDocument: double
+ HungarianName: string
+ IsMeasurable: bool
+ IsMeasured: bool
+ MeasuredGrossWeight: double
+ MeasuredNetWeight: double
+ MeasuredQuantity: int
+ MeasuringCount: int
+ MeasuringStatus: MeasuringStatus
+ constraints: "readonly, not-mapped"
+ Modified: DateTime
+ Name: string
+ NameOnDocument: string
+ NetWeightOnDocument: double
+ Pallet: Pallet
+ foreign-key: "PalletId"
+ navigation: "many-to-one"
+ PalletId: int?
+ PalletsOnDocument: int
+ ProductDto: ProductDto
+ foreign-key: "ProductId"
+ navigation: "many-to-one"
+ ProductId: int?
+ QuantityOnDocument: int
+ ShippingDocument: ShippingDocument
+ foreign-key: "ShippingDocumentId"
+ navigation: "many-to-one"
+ ShippingDocumentId: int
+ ShippingItemPallets: List
+ navigation: "one-to-many"
+ UnitPriceOnDocument: double
+ Id: int
+ primary-key: true
+
+ ShippingItemPallet: "Measurement record for incoming goods"
+ table-name: "fbShippingItemPallet"
+ purpose: "Always created even without physical pallet"
+ ShippingItem: ShippingItem
+ foreign-key: "ShippingItemId"
+ navigation: "many-to-one"
+ ShippingItemId: int
+ Created: DateTime
+ CreatorId: int?
+ GrossWeight: double
+ IsMeasured: bool
+ MeasuringStatus: MeasuringStatus
+ constraints: "readonly, not-mapped"
+ Modified: DateTime
+ ModifierId: int?
+ NetWeight: double
+ business-logic: "get => GrossWeight - PalletWeight - (TrayQuantity * TareWeight)"
+ constraints: "readonly, not-mapped"
+ PalletWeight: double
+ TareWeight: double
+ TrayQuantity: int
+ Id: int
+ primary-key: true
+
+ StockTaking: "Inventory session record"
+ table-name: "fbStockTaking"
+ Created: DateTime
+ Creator: int
+ IsClosed: bool
+ Modified: DateTime
+ StartDateTime: DateTime
+ StockTakingItems: List
+ navigation: "one-to-many"
+ Id: int
+ primary-key: true
+
+ StockTakingItem: "Line item for product reconciliation"
+ table-name: "fbStockTakingItem"
+ purpose: "Reconciles snapshot quantity with physical count"
+ InProcessOrdersQuantity: int
+ IsInvalid: bool
+ constraints: "readonly, not-mapped"
+ IsMeasurable: bool
+ IsRequiredForMeasuring: bool
+ constraints: "readonly, not-mapped"
+ MeasuredNetWeight: double
+ NetWeightDiff: double
+ constraints: "readonly, not-mapped"
+ OriginalNetWeight: double
+ QuantityDiff: int
+ constraints: "readonly, not-mapped"
+ StockTakingItemPallets: List
+ navigation: "one-to-many"
+ TotalOriginalQuantity: int
+ constraints: "readonly, not-mapped"
+ Created: DateTime
+ IsMeasured: bool
+ MeasuredStockQuantity: int
+ Modified: DateTime
+ OriginalStockQuantity: int
+ Product: ProductDto
+ foreign-key: "ProductId"
+ navigation: "many-to-one"
+ ProductId: int
+ StockTaking: StockTaking
+ foreign-key: "StockTakingId"
+ navigation: "many-to-one"
+ StockTakingId: int
+ Id: int
+ primary-key: true
+
+ StockTakingItemPallet: "Weight record for inventory item"
+ table-name: "fbStockTakingItemPallet"
+ purpose: "Mandatory for every inventory item, even non-measurable"
+ StockTakingItem: StockTakingItem
+ foreign-key: "StockTakingItemId"
+ navigation: "many-to-one"
+ StockTakingItemId: int
+ Created: DateTime
+ CreatorId: int?
+ GrossWeight: double
+ IsMeasured: bool
+ MeasuringStatus: MeasuringStatus
+ constraints: "readonly, not-mapped"
+ Modified: DateTime
+ ModifierId: int?
+ NetWeight: double
+ business-logic: "get => GrossWeight - PalletWeight - (TrayQuantity * TareWeight)"
+ constraints: "readonly, not-mapped"
+ PalletWeight: double
+ TareWeight: double
+ TrayQuantity: int
+ Id: int
+ primary-key: true
+}
diff --git a/Nop.Plugin.Misc.AIPlugin/docs/SIGNALR_ENDPOINTS.md b/Nop.Plugin.Misc.AIPlugin/docs/SIGNALR_ENDPOINTS.md
new file mode 100644
index 0000000..6217ca0
--- /dev/null
+++ b/Nop.Plugin.Misc.AIPlugin/docs/SIGNALR_ENDPOINTS.md
@@ -0,0 +1,166 @@
+# SignalR Endpoints and External Integrations
+
+> Part of `Nop.Plugin.Misc.FruitBankPlugin`. See `README.md` for project overview.
+> For measurement workflows see `docs/MEASUREMENT.md`.
+> For data layer see `docs/DATA_LAYER.md`.
+
+The plugin communicates with FruitBankHybridApp (MAUI client) via SignalR. Methods decorated with `[SignalR]` attributes auto-broadcast results to connected clients.
+
+## SignalR Configuration
+
+Configured in `PluginNopStartup`:
+
+| Setting | Value |
+|---|---|
+| KeepAliveInterval | 30 seconds |
+| ClientTimeoutInterval | 30 seconds |
+| MaximumReceiveMessageSize | 30 MB |
+| EnableDetailedErrors | true |
+
+Hubs registered: `DevAdminSignalRHub`, `LoggerSignalRHub` (from AyCode.Core.Server).
+
+## FruitBankHub
+
+`Services/FruitBankHub.cs` — Minimal hub inheriting `Hub`.
+
+| Method | Purpose |
+|---|---|
+| `SendMessage(string user, string message)` | Broadcast message to all connected clients |
+
+## CustomOrderSignalREndpoint
+
+`Areas/Admin/Controllers/CustomOrderSignalREndpoint.cs` — Order management via SignalR. Implements `ICustomOrderSignalREndpointServer`.
+
+### Query Methods
+
+| Method | Parameters | Returns | Broadcast Tag |
+|---|---|---|---|
+| `GetAllOrderDtos()` | — | Orders from last 15 days with relations | — |
+| `GetOrderDtoById` | orderId | Single order with relations | — |
+| `GetPendingOrderDtos()` | — | Pending status orders | — |
+| `GetPendingOrderDtosForMeasuring` | lastDaysCount | Orders for measurement (unpaid, not cancelled, with DateOfReceipt) | — |
+| `GetAllOrderDtoByIds` | orderIds[] | Batch retrieval | — |
+| `GetAllOrderDtoByProductId` | productId | Orders containing product | — |
+
+### Mutation Methods
+
+| Method | Parameters | Broadcast Tag | Key Logic |
+|---|---|---|---|
+| `StartMeasuring` | orderId, userId | `SendOrderChanged` | Sets MeasurementOwnerId, OrderStatus=Processing, PaymentStatus=Pending |
+| `SetOrderStatusToComplete` | orderId, revisorId | `SendOrderChanged` | Validates pallets, recalculates prices, updates stock, publishes OrderPaidEvent |
+| `AddOrUpdateMeasuredOrderItemPallet` | OrderItemPallet | `SendOrderItemPalletChanged` | Saves pallet, triggers price recalculation |
+
+### Order Item & Pallet Queries
+
+| Method | Purpose |
+|---|---|
+| `GetOrderItemDtosByOrderId` | Items for a specific order |
+| `GetOrderItemPalletsByOrderItemId` | Pallets for a specific item |
+| `GetOrderItemPalletById` | Single pallet |
+
+## StockSignalREndpointServer
+
+`Areas/Admin/Controllers/StockSignalREndpointServer.cs` — Inventory management via SignalR.
+
+| Method | Parameters | Key Logic |
+|---|---|---|
+| `GetStockTakings` | loadRelations | All stock taking sessions |
+| `AddStockTaking` | StockTaking | Creates session, auto-populates items from products + pending order items |
+| `CloseStockTaking` | stockTakingId | Validates all measured, applies stock corrections, marks closed |
+| `UpdateStockTaking` | StockTaking | Updates session |
+| `RefreshStockTakingItem` | stockTakingItemId | Refreshes measured values from pallets |
+| `GetStockTakingItems` | loadRelations | All items |
+| `GetStockTakingItemsByStockTakingId` | stockTakingId | Filtered items |
+| `AddOrUpdateMeasuredStockTakingItemPallet` | StockTakingItemPallet | Saves pallet, refreshes item values |
+
+## FruitBankDataController
+
+`Controllers/FruitBankDataController.cs` — Main data API implementing `IFruitBankDataControllerServer`. Methods decorated with `[SignalR]` attributes for FruitBankHybridApp integration.
+
+### Shipping Operations
+
+| Method | Purpose |
+|---|---|
+| `GetShippings()` | Last 15 days with relations |
+| `GetNotMeasuredShippings()` | Incomplete shipments |
+| `GetShippingById` / `AddShipping` / `UpdateShipping` | CRUD |
+| `GetShippingItems` / `AddShippingItem` / `UpdateShippingItem` | Item CRUD |
+| `GetShippingItemPallets` / `AddShippingItemPallet` / `UpdateShippingItemPallet` | Pallet CRUD |
+| `AddOrUpdateMeasuredShippingItemPallet` | Measurement + average weight update + cascade |
+| `GetShippingDocuments` / `AddShippingDocument` | Document operations, updates product costs on add |
+| `ProcessAndSaveFullShippingJson` | Parses FullProcessModel JSON, saves complete shipping data |
+
+### Partner Operations
+
+| Method | Purpose |
+|---|---|
+| `GetPartners()` | All partners |
+| `GetPartnerById` / `AddPartner` / `UpdatePartner` | CRUD |
+
+### Product & Attribute Operations
+
+| Method | Purpose |
+|---|---|
+| `GetProductDtos()` | All products with GenericAttributes |
+| `GetProductDtoById` | Single product |
+| `GetGenericAttributeDtosByEntityIdAndKeyGroup` | Custom attributes by entity + key group |
+| `AddGenericAttributeDto` / `UpdateGenericAttributeDto` | Attribute CRUD |
+
+### User & Auth Operations
+
+| Method | Purpose |
+|---|---|
+| `GetMeasuringUsers()` | Users in "Measuring" role |
+| `GetCustomerRolesByCustomerId` | Customer roles |
+| `LoginMeasuringUser` | Authentication with role check |
+
+### Stock History
+
+| Method | Purpose |
+|---|---|
+| `GetStockQuantityHistoryDtos()` | Last 15 days |
+| `GetStockQuantityHistoryDtosByProductId` | Product-filtered history |
+
+## InnVoice Integration (Billingo)
+
+### InnVoiceOrderService
+
+`Services/InnVoiceOrderService.cs` — Order synchronization with Billingo/InnVoice accounting system via XML API.
+
+| Method | Purpose |
+|---|---|
+| `CreateOrdersAsync(List)` | Batch order creation |
+| `CreateOrderAsync(OrderCreateRequest)` | Single order creation |
+| `GetOrdersByUpdateTimeAsync(DateTime)` | Query by last update time |
+| `GetOrdersByDateAsync(DateTime)` | Query by order date |
+| `GetOrderByTechIdAsync(int)` | Query by InnVoice tech ID |
+| `GetOrderByTableIdAsync(int)` | Query by table ID |
+
+**Models:** `OrderCreateRequest`, `OrderCreateResponse`, `InnVoiceOrder`, `InnVoiceOrderLineItem`
+
+### InnvoiceApiService
+
+`Services/InnvoiceApiService.cs` — Invoice CRUD with Billingo XML API.
+
+| Method | Purpose |
+|---|---|
+| `GetInvoiceByIdAsync` / `GetInvoiceByNumberAsync` / `GetInvoiceByTechIdAsync` | Invoice queries |
+| `GetInvoicesByDateRangeAsync` | Date-range filtered invoices |
+| `CreateInvoiceAsync` / `UpdateInvoiceAsync` | Invoice CRUD |
+
+**Models:** `Invoice`, `InvoiceLineItem`, `InvoiceCreateRequest`, `InvoiceCreateResponse`
+
+Both services build and parse XML payloads. Called from `InnVoiceOrderController` and `InnVoiceOrderSyncController`.
+
+## FruitBankAttributeService
+
+`Services/FruitBankAttributeService.cs` — GenericAttribute CRUD helper for measurement-related attributes.
+
+| Method | Purpose |
+|---|---|
+| `GetGenericAttributeValueAsync` | Type-safe attribute retrieval |
+| `GetMeasuringAttributeValuesAsync` | Get IsMeasurable, NetWeight, Tare, etc. as batch |
+| `InsertOrUpdateMeasuringAttributeValuesAsync` | Upsert measurement attributes |
+| `IsMeasurableEntityAsync` | Check if entity's product is measurable |
+
+Supports cumulative weight updates (adds to existing value rather than replacing).