< Summary

Information
Class: Services.AiAssistant.AiOrderWriteService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/AiAssistant/AiOrderWriteService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 1033
Coverable lines: 1033
Total lines: 1562
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 570
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/AiAssistant/AiOrderWriteService.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.Logging;
 3using OzonDomains;
 4using OzonDomains.Models;
 5using OzonRepositories.Context;
 6using Services.ApiServices._1CApi;
 7using Services.ApiServices._1CApi.DTO;
 8using Services.ApiServices._1CApi.Models;
 9using Services.ApiServices.OpenRouterApi.Models;
 10using Services.DataServices;
 11using Services.PriceСlculationServices;
 12using Services.TransactionUtilsServices;
 13
 14namespace Services.AiAssistant;
 15
 16/// <summary>
 17/// Строит предложения write-действий (proposal) и выполняет их по запросу из AI-контроллера.
 18/// </summary>
 19public class AiOrderWriteService
 20{
 21    private readonly ApplicationDbContext    _db;
 22    private readonly AiOrderScopeApplier     _scopeApplier;
 23    private readonly AppStatusDataServices   _appStatuses;
 24    private readonly SupplierDataServices    _suppliers;
 25    private readonly WarehouseDataServices   _warehouses;
 26    private readonly TransactionManager      _transactionManager;
 27    private readonly OrderPriceManager       _priceManager;
 28    private readonly OneCReceiptManager      _oneCReceiptManager;
 29    private readonly OneCTransferManager     _oneCTransferManager;
 30    private readonly ILogger<AiOrderWriteService> _logger;
 31
 32    // URL-шаблоны для ручного выполнения транзакций (из TransactionTransitionRuleVm.Endpoint)
 033    private static readonly Dictionary<TransactionType, string> TransactionEndpoints = new()
 034    {
 035        [TransactionType.OrderedToSupplier]   = "/Studio2/OrderToSupplierTransaction/CreateOrderToSupplierTransactionV2"
 036        [TransactionType.OrderedToSeller]     = "/Studio2/OrderedToSellerTransaction/CreateOrderedToSellerTransaction",
 037        [TransactionType.ShippedBySupplier]   = "/Studio2/ShippedBySupplierTransaction/CreateShippedBySupplierTransactio
 038        [TransactionType.ShippedToSeller]     = "/Studio2/ShippedToSellerTransaction/CreateShippedToSellerTransaction",
 039        [TransactionType.AcceptedToWarehouse] = "/Studio2/AcceptedToWarehouseTransaction/AcceptedToWarehouseTransaction"
 040        [TransactionType.ShippedToClient]     = "/Studio2/ShippedToClientTransaction/CreateShippedToClientTransaction",
 041        [TransactionType.CustomerReturn]      = "/Studio2/CustomerReturnTransaction/CreateCustomerReturnTransaction",
 042        [TransactionType.ReturnToSupplier]    = "/Studio2/ReturnToSupplierTransaction/CreateReturnToSupplierTransaction"
 043        [TransactionType.ReturnToWarehouse]   = "/Studio2/ReturnToWarehouseTransaction/CreateReturnToWarehouseTransactio
 044        [TransactionType.WarehouseChange]     = "/Studio2/WarehouseChangeTransaction/CreateWarehouseChangeTransaction",
 045        [TransactionType.WarehouseTransfer]   = "/Studio2/WarehouseTransferTransaction/CreateWarehouseTransferTransactio
 046        [TransactionType.ShippedToCommissioner] = "/Studio2/ShippedToCommissionerTransaction/CreateShippedToCommissioner
 047        [TransactionType.ToCancellation]      = "/Studio2/ToCancellationTransaction/CreateToCancellationTransaction",
 048        [TransactionType.Cancelled]           = "/Studio2/CancelledTransaction/CreateCancelledTransaction",
 049        [TransactionType.ReturnTransfer]      = "/Studio2/ReturnTransferTransaction/CreateReturnTransferTransaction",
 050        [TransactionType.LostByCommissioner]  = "/Studio2/LostByCommissionerTransaction/CreateLostByCommissionerTransact
 051    };
 52
 53    // Транзакции, не требующие 1С (IntegrationMode = "none")
 054    private static readonly HashSet<TransactionType> NoOneCTransactions = new()
 055    {
 056        TransactionType.OrderedToSupplier,
 057        TransactionType.OrderedToSeller,
 058        TransactionType.AcceptedToWarehouse,  // только фиксация приёма, 1С вне этой формы
 059        TransactionType.ToCancellation,
 060        TransactionType.Cancelled,
 061        TransactionType.WarehouseChange,
 062        TransactionType.ReturnTransfer,
 063        TransactionType.Percentage,
 064    };
 65
 66    // Для транзакций с 1С указываем тип документа
 067    private static readonly Dictionary<TransactionType, string> OneCDocumentTypes = new()
 068    {
 069        [TransactionType.ShippedBySupplier]     = "Приходная накладная (receipt)",
 070        [TransactionType.ShippedToSeller]       = "Перемещение (transfer)",
 071        [TransactionType.ShippedToClient]       = "Перемещение + расходная накладная (transfer+expense)",
 072        [TransactionType.CustomerReturn]        = "Перемещение (transfer)",
 073        [TransactionType.ReturnToSupplier]      = "Перемещение (transfer)",
 074        [TransactionType.ReturnToWarehouse]     = "Перемещение (transfer)",
 075        [TransactionType.WarehouseTransfer]     = "Перемещение (transfer)",
 076        [TransactionType.ShippedToCommissioner] = "Перемещение (transfer)",
 077        [TransactionType.LostByCommissioner]    = "Перемещение (transfer)",
 078    };
 79
 080    public AiOrderWriteService(
 081        ApplicationDbContext    db,
 082        AiOrderScopeApplier    scopeApplier,
 083        AppStatusDataServices  appStatuses,
 084        SupplierDataServices   suppliers,
 085        WarehouseDataServices  warehouses,
 086        TransactionManager     transactionManager,
 087        OrderPriceManager      priceManager,
 088        OneCReceiptManager     oneCReceiptManager,
 089        OneCTransferManager    oneCTransferManager,
 090        ILogger<AiOrderWriteService> logger)
 91    {
 092        _db                  = db;
 093        _scopeApplier        = scopeApplier;
 094        _appStatuses         = appStatuses;
 095        _suppliers           = suppliers;
 096        _warehouses          = warehouses;
 097        _transactionManager  = transactionManager;
 098        _priceManager        = priceManager;
 099        _oneCReceiptManager  = oneCReceiptManager;
 0100        _oneCTransferManager = oneCTransferManager;
 0101        _logger              = logger;
 0102    }
 103
 104    // ── Построение предложения для редактирования полей ────────────────────────
 105
 106    /// <summary>
 107    /// Строит proposal для редактирования полей заказа.
 108    /// Пытается разрешить имена поставщика/статуса/склада из сообщения пользователя.
 109    /// </summary>
 110    public async Task<AiActionProposalDto?> BuildEditProposalAsync(
 111        int     orderId,
 112        string  userMessage,
 113        string? userId,
 114        CancellationToken ct)
 115    {
 0116        var order = await LoadOrderAsync(orderId, userId, ct);
 0117        if (order == null) return null;
 118
 0119        var dto = new AiOrderEditDto();
 0120        var missing = new List<string>();
 121
 122        // Пытаемся извлечь каждое поле из сообщения
 0123        var supplierName  = AiWriteIntentParser.TryExtractSupplierName(userMessage);
 0124        var statusName    = AiWriteIntentParser.TryExtractStatusName(userMessage);
 0125        var warehouseName = AiWriteIntentParser.TryExtractWarehouseName(userMessage);
 0126        var price         = AiWriteIntentParser.TryExtractPrice(userMessage);
 0127        var quantity      = AiWriteIntentParser.TryExtractQuantity(userMessage);
 128
 0129        var lower = userMessage.ToLowerInvariant();
 0130        bool wantsSupplier  = lower.Contains("поставщик");
 0131        bool wantsStatus    = lower.Contains("статус");
 0132        bool wantsWarehouse = lower.Contains("склад");
 0133        bool wantsPrice     = lower.Contains("цен");
 0134        bool wantsQuantity  = lower.Contains("количеств");
 135
 0136        if (wantsSupplier)
 137        {
 0138            if (!string.IsNullOrWhiteSpace(supplierName))
 139            {
 0140                var supplier = await FindSupplierFuzzyAsync(supplierName, ct);
 0141                if (supplier != null)
 142                {
 0143                    dto.SupplierId   = supplier.Id;
 0144                    dto.SupplierName = supplier.Name;
 145                }
 146                else
 0147                    missing.Add($"поставщик «{supplierName}» не найден в базе — уточните название");
 148            }
 149            else
 0150                missing.Add("имя поставщика (укажите, например: «поставщик Interparts»)");
 151        }
 152
 0153        if (wantsStatus)
 154        {
 0155            if (!string.IsNullOrWhiteSpace(statusName))
 156            {
 0157                var status = await FindStatusFuzzyAsync(statusName, ct);
 0158                if (status != null)
 159                {
 0160                    dto.AppStatusId   = status.Id;
 0161                    dto.AppStatusName = status.Name;
 162                }
 163                else
 0164                    missing.Add($"статус «{statusName}» не найден в базе — уточните название");
 165            }
 166            else
 0167                missing.Add("название нового статуса");
 168        }
 169
 0170        if (wantsWarehouse)
 171        {
 0172            if (!string.IsNullOrWhiteSpace(warehouseName))
 173            {
 0174                var warehouse = await FindWarehouseFuzzyAsync(warehouseName, ct);
 0175                if (warehouse != null)
 176                {
 0177                    dto.ShipmentWarehouseId = warehouse.Id;
 0178                    dto.WarehouseName       = warehouse.Name;
 179                }
 180                else
 0181                    missing.Add($"склад «{warehouseName}» не найден в базе — уточните название");
 182            }
 183            else
 0184                missing.Add("название склада");
 185        }
 186
 0187        if (wantsPrice)
 188        {
 0189            if (price.HasValue)
 0190                dto.PurchasePrice = price;
 191            else
 0192                missing.Add("новая цена закупки (число в рублях)");
 193        }
 194
 0195        if (wantsQuantity)
 196        {
 0197            if (quantity.HasValue)
 0198                dto.Quantity = quantity;
 199            else
 0200                missing.Add("новое количество (целое число)");
 201        }
 202
 203        // Нет ни одного поля — не распознали запрос
 0204        if (!wantsSupplier && !wantsStatus && !wantsWarehouse && !wantsPrice && !wantsQuantity)
 0205            return null;
 206
 0207        return new AiActionProposalDto
 0208        {
 0209            Kind           = "edit_order",
 0210            OrderId        = orderId,
 0211            ShipmentNumber = order.ShipmentNumber,
 0212            CurrentStatus  = order.AppStatus?.Name,
 0213            EditDto        = dto,
 0214            ManualPageUrl  = $"/Studio2/Orders/MultiplayEditV2?ids={orderId}&page=1",
 0215            MissingFields  = missing.Count > 0 ? missing : null,
 0216        };
 0217    }
 218
 219    // ── Построение предложения для транзакции ──────────────────────────────────
 220
 221    public async Task<AiActionProposalDto?> BuildTransactionProposalAsync(
 222        int             orderId,
 223        TransactionType type,
 224        string          userMessage,
 225        string?         userId,
 226        CancellationToken ct)
 227    {
 0228        var order = await LoadOrderAsync(orderId, userId, ct);
 0229        if (order == null) return null;
 230
 231        // ── Извлекаем данные из сообщения (цена, поставщик) ──────────────────
 0232        var msgPrice    = AiWriteIntentParser.TryExtractPrice(userMessage);
 0233        var msgSupplier = AiWriteIntentParser.TryExtractSupplierName(userMessage);
 234
 235        // Применяем извлечённые данные: если пользователь указал цену — используем её,
 236        // иначе берём текущую из заказа
 0237        var effectivePrice = msgPrice ?? order.PurchasePrice ?? order.CostPrice ?? 0;
 238
 239        // Поиск поставщика по имени если указан в сообщении
 240        // "Не указан" — системная запись, считаем её отсутствием поставщика
 0241        var supplierIsEmpty = order.SupplierId == null || order.Supplier?.Name == "Не указан";
 0242        int? supplierId   = supplierIsEmpty ? null : order.SupplierId;
 0243        string? supplierName = supplierIsEmpty ? null : order.Supplier?.Name;
 0244        if (!string.IsNullOrWhiteSpace(msgSupplier) && supplierIsEmpty)
 245        {
 0246            var found = await FindSupplierFuzzyAsync(msgSupplier, ct);
 0247            if (found != null) { supplierId = found.Id; supplierName = found.Name; }
 248        }
 249
 250        // ── Обязательные поля: проверяем с учётом только что введённых данных ─
 251        // Создаём временный Order-proxy с обновлёнными полями для валидации
 0252        var orderForValidation = new Order
 0253        {
 0254            AppStatusId         = order.AppStatusId,
 0255            AppStatus           = order.AppStatus,
 0256            ClientStatus        = order.ClientStatus,
 0257            SupplierId          = supplierId,
 0258            Supplier            = supplierId != null ? order.Supplier : null,
 0259            ShipmentWarehouseId = order.ShipmentWarehouseId,
 0260            ShipmentWarehouse   = order.ShipmentWarehouse,
 0261            PurchasePrice       = (decimal)effectivePrice,
 0262            CostPrice           = (decimal)effectivePrice,
 0263        };
 0264        var missing = GetOrderLevelMissingFields(type, orderForValidation);
 265
 266        // ── Склады-отправитель/получатель ─────────────────────────────────────
 0267        string? warehouseSender    = null;
 0268        string? warehouseRecipient = null;
 269
 0270        if (AiWriteIntentParser.NeedsWarehouses(type))
 271        {
 0272            warehouseSender    = AiWriteIntentParser.TryExtractWarehouseSender(userMessage);
 0273            warehouseRecipient = AiWriteIntentParser.TryExtractWarehouseRecipient(userMessage);
 0274            missing.AddRange(AiWriteIntentParser.GetMissingTransactionFields(type, warehouseSender, warehouseRecipient))
 275        }
 276
 0277        TransactionEndpoints.TryGetValue(type, out var endpoint);
 0278        OneCDocumentTypes.TryGetValue(type, out var oneCDocType);
 279        // ShippedBySupplier, ShippedToSeller, ShippedToCommissioner — агент проводит сам
 0280        var needs1CManual = !NoOneCTransactions.Contains(type)
 0281                            && type != TransactionType.ShippedBySupplier
 0282                            && type != TransactionType.ShippedToSeller
 0283                            && type != TransactionType.ShippedToCommissioner;
 0284        var needs1CInput  = type is TransactionType.ShippedBySupplier
 0285                                 or TransactionType.ShippedToSeller
 0286                                 or TransactionType.ShippedToCommissioner;
 287
 288        // Для ShippedBySupplier: предзаполняем поля 1С
 0289        string? autoNumberVh  = null;
 0290        string? autoComment   = null;
 0291        string? supplierInn   = null;
 0292        if (needs1CInput)
 293        {
 0294            var supplierForInn = supplierId != null
 0295                ? await _db.Set<OzonDomains.Models.Supplier>().AsNoTracking()
 0296                    .FirstOrDefaultAsync(s => s.Id == supplierId, ct)
 0297                : order.Supplier;
 0298            supplierInn  = supplierForInn?.INNCode?.Trim();
 0299            autoNumberVh = $"А-{DateTime.Now:ddMMyyyy}";
 0300            var supplierLabel = supplierName ?? supplierForInn?.Name ?? "поставщик";
 0301            autoComment  = $"Отгружен поставщиком | {supplierLabel} | {order.ShipmentNumber}";
 302        }
 303
 0304        return new AiActionProposalDto
 0305        {
 0306            Kind           = "create_transaction",
 0307            OrderId        = orderId,
 0308            ShipmentNumber = order.ShipmentNumber,
 0309            CurrentStatus  = order.AppStatus?.Name,
 0310            SupplierInn    = supplierInn,
 0311            TransactionDto = new AiTransactionParamsDto
 0312            {
 0313                TransactionTypeValue   = (int)type,
 0314                TransactionLabel       = type.GetDisplayName(),
 0315                WarehouseSenderName    = warehouseSender,
 0316                WarehouseRecipientName = warehouseRecipient,
 0317                PurchasePrice          = msgPrice,
 0318                SupplierId             = supplierId != order.SupplierId ? supplierId : null,
 0319                SupplierName           = supplierId != order.SupplierId ? supplierName : null,
 0320                ProcessIn1C            = true,
 0321                NumberVh               = autoNumberVh,
 0322                OneCComment            = autoComment,
 0323                VatRate                = 22,
 0324            },
 0325            ManualPageUrl    = endpoint != null ? $"{endpoint}?ids={orderId}&page=1" : null,
 0326            MissingFields    = missing.Count > 0 ? missing : null,
 0327            Requires1CManual = needs1CManual,
 0328            Requires1CInput  = needs1CInput,
 0329            OneCDocumentType = oneCDocType,
 0330        };
 0331    }
 332
 333    // Допустимые исходные статусы для транзакций (fallback, если StatusTransitions не настроены)
 0334    private static readonly HashSet<string> OrderedToSupplierAllowed  = ["Не указан", "Заказан поставщику", "Заказан реа
 335    // "К отмене" не допускается для реализатора (в отличие от поставщика)
 0336    private static readonly HashSet<string> OrderedToSellerAllowed    = ["Не указан", "Заказан поставщику", "Заказан реа
 0337    private static readonly HashSet<string> ToCancellationAllowed     = ["Не указан", "Заказан поставщику", "Заказан реа
 0338    private static readonly HashSet<string> CancelledAllowed          = ["Заказан поставщику", "К отмене", "Не указан"];
 0339    private static readonly HashSet<string> WarehouseChangeAllowed    = ["Не указан", "Заказан поставщику", "Заказан реа
 0340                                                                         "Отгружен поставщиком", "Принят на складе", "К 
 0341    private static readonly HashSet<string> ReturnTransferAllowed     = ["Возврат покупателя"];
 342
 343    /// <summary>
 344    /// Проверяет обязательные поля и допустимость состояния заказа для данного типа транзакции.
 345    /// Возвращает список блокирующих проблем (если пусто — транзакция разрешена).
 346    /// </summary>
 347    private static List<string> GetOrderLevelMissingFields(TransactionType type, Order order)
 348    {
 0349        var missing = new List<string>();
 0350        var appStatus    = order.AppStatus?.Name ?? "Не указан";
 0351        var clientStatus = order.ClientStatus?.Name ?? "";
 352
 353        switch (type)
 354        {
 355            // ── Заказан поставщику ──────────────────────────────────────────────
 356            case TransactionType.OrderedToSupplier:
 357                // Блокировки по клиентскому статусу Ozon
 0358                if (clientStatus == "Отменен покупателем")
 0359                    missing.Add("заказ отменён покупателем на Ozon — транзакция недоступна");
 0360                else if (appStatus == "Не указан" && clientStatus == "Отменён")
 0361                    missing.Add("заказ отменён на Ozon (статус Ozon: «Отменён») — транзакция недоступна");
 0362                else if (appStatus == "Отменен" && clientStatus == "Отменён")
 0363                    missing.Add("заказ уже отменён — транзакция недоступна");
 364                // Проверка допустимого исходного статуса
 0365                else if (!OrderedToSupplierAllowed.Contains(appStatus))
 0366                    missing.Add($"статус «{appStatus}» не допускает эту транзакцию (ожидается: Не указан / Заказан поста
 367                // Обязательные поля
 0368                if (order.SupplierId == null || order.Supplier?.Name == "Не указан")
 0369                    missing.Add("поставщик не назначен — укажите поставщика в заказе");
 0370                if ((order.PurchasePrice ?? order.CostPrice ?? 0) <= 0)
 0371                    missing.Add("цена закупки не указана или равна 0 — укажите цену закупки");
 0372                break;
 373
 374            // ── Заказан реализатору ─────────────────────────────────────────────
 375            case TransactionType.OrderedToSeller:
 376                // Блокировки по клиентскому статусу (аналог OrderedToSupplier)
 0377                if (clientStatus == "Отменен покупателем")
 0378                    missing.Add("заказ отменён покупателем на Ozon — транзакция недоступна");
 0379                else if (appStatus == "Не указан" && clientStatus == "Отменён")
 0380                    missing.Add("заказ отменён на Ozon (статус Ozon: «Отменён») — транзакция недоступна");
 0381                else if (appStatus == "Отменен" && clientStatus == "Отменён")
 0382                    missing.Add("заказ уже отменён — транзакция недоступна");
 0383                else if (!OrderedToSellerAllowed.Contains(appStatus))
 0384                    missing.Add($"статус «{appStatus}» не допускает эту транзакцию (ожидается: Не указан / Заказан поста
 385                // Обязательные поля (мягкая валидация на submit, но сервер требует поставщика)
 0386                if (order.SupplierId == null || order.Supplier?.Name == "Не указан")
 0387                    missing.Add("реализатор (поставщик) не назначен — укажите поставщика");
 0388                if ((order.PurchasePrice ?? order.CostPrice ?? 0) <= 0)
 0389                    missing.Add("цена закупки не указана или равна 0 — укажите цену закупки");
 0390                break;
 391
 392            // ── Отгружен поставщиком (1С: приходная) ───────────────────────────
 393            case TransactionType.ShippedBySupplier:
 0394                if (appStatus != "Заказан поставщику")
 0395                    missing.Add($"статус «{appStatus}» не допускает эту транзакцию (ожидается: Заказан поставщику)");
 0396                if (order.SupplierId == null)
 0397                    missing.Add("поставщик не назначен");
 0398                if ((order.PurchasePrice ?? order.CostPrice ?? 0) <= 0)
 0399                    missing.Add("цена закупки не указана или равна 0");
 0400                break;
 401
 402            // ── Отгружен реализатором (1С: перемещение) ────────────────────────
 403            case TransactionType.ShippedToSeller:
 0404                if (appStatus != "Заказан реализатору")
 0405                    missing.Add($"статус «{appStatus}» не допускает эту транзакцию (ожидается: Заказан реализатору)");
 0406                break;
 407
 408            // ── Принят на складе (учётная операция, без 1С) ────────────────────
 409            case TransactionType.AcceptedToWarehouse:
 0410                if (appStatus is not ("Заказан поставщику" or "Не указан" or "Отгружен поставщиком"))
 0411                    missing.Add($"статус «{appStatus}» не допускает эту транзакцию (ожидается: Заказан поставщику / Не у
 0412                if (order.SupplierId == null || order.Supplier?.Name == "Не указан")
 0413                    missing.Add("поставщик не назначен — укажите поставщика в заказе");
 0414                if ((order.PurchasePrice ?? order.CostPrice ?? 0) <= 0)
 0415                    missing.Add("цена закупки не указана или равна 0 — укажите цену закупки");
 0416                break;
 417
 418            // ── Отгружен комиссионеру (1С: перемещение с перевёрнутыми складами) ─
 419            case TransactionType.ShippedToCommissioner:
 0420                if (clientStatus == "Отменен покупателем")
 0421                    missing.Add("заказ отменён покупателем на Ozon — транзакция недоступна");
 0422                if (order.SupplierId == null || order.Supplier?.Name == "Не указан")
 0423                    missing.Add("поставщик не назначен — укажите поставщика в заказе");
 0424                if ((order.PurchasePrice ?? order.CostPrice ?? 0) <= 0)
 0425                    missing.Add("цена закупки не указана или равна 0 — укажите цену закупки");
 0426                break;
 427
 428            // ── Отгружен клиенту (1С: перемещение + расходная) ─────────────────
 429            case TransactionType.ShippedToClient:
 0430                if (order.ShipmentWarehouseId == null)
 0431                    missing.Add("склад отгрузки не назначен");
 0432                break;
 433
 434            // ── К отмене ────────────────────────────────────────────────────────
 435            case TransactionType.ToCancellation:
 0436                if (!ToCancellationAllowed.Contains(appStatus))
 0437                    missing.Add($"статус «{appStatus}» не допускает эту транзакцию (ожидается: Не указан / Заказан поста
 0438                break;
 439
 440            // ── Отменен ─────────────────────────────────────────────────────────
 441            case TransactionType.Cancelled:
 0442                if (!CancelledAllowed.Contains(appStatus))
 0443                    missing.Add($"статус «{appStatus}» не допускает отмену (ожидается: Не указан / Заказан поставщику / 
 0444                if (appStatus == "Отменен")
 0445                    missing.Add("заказ уже отменён");
 0446                break;
 447
 448            // ── Изменение склада ─────────────────────────────────────────────────
 449            case TransactionType.WarehouseChange:
 0450                if (!WarehouseChangeAllowed.Contains(appStatus))
 0451                    missing.Add($"статус «{appStatus}» не допускает эту транзакцию");
 0452                break;
 453
 454            // ── Возврат покупателя (1С: перемещение) ────────────────────────────
 455            case TransactionType.CustomerReturn:
 0456                if (appStatus is not ("Отгружен клиенту" or "Отменен"))
 0457                    missing.Add($"статус «{appStatus}» не допускает возврат покупателя (ожидается: Отгружен клиенту / От
 0458                break;
 459
 460            // ── Перемещение (Return) ─────────────────────────────────────────────
 461            case TransactionType.ReturnTransfer:
 0462                if (!ReturnTransferAllowed.Contains(appStatus))
 0463                    missing.Add($"статус «{appStatus}» не допускает эту транзакцию (ожидается: Возврат покупателя)");
 464                break;
 465        }
 0466        return missing;
 467    }
 468
 469    // ── Выполнение редактирования заказа ────────────────────────────────────────
 470
 471    public async Task<AiExecuteActionResponse> ExecuteEditAsync(
 472        int            orderId,
 473        AiOrderEditDto dto,
 474        string         userName,
 475        CancellationToken ct,
 476        string?        model = null)
 477    {
 478        try
 479        {
 0480            var order = await _db.Orders
 0481                .Include(o => o.AppStatus)
 0482                .Include(o => o.Supplier)
 0483                .Include(o => o.ShipmentWarehouse)
 0484                .FirstOrDefaultAsync(o => o.Id == orderId, ct);
 485
 0486            if (order == null)
 0487                return Fail($"Заказ #{orderId} не найден.");
 488
 0489            var changedFields = new List<string>();
 0490            var oldStatusId   = order.AppStatusId;
 491            // "Иван Иванов [AI Стас: deepseek/deepseek-v4-flash]"
 0492            var historyAuthor = FormatHistoryAuthor(userName, model);
 493
 0494            if (dto.AppStatusId.HasValue && dto.AppStatusId != order.AppStatusId)
 495            {
 0496                var oldName = order.AppStatus?.Name ?? "—";
 0497                order.AppStatusId = dto.AppStatusId.Value;
 0498                order.LastStatusChangeDate = DateTime.Now;
 0499                changedFields.Add($"Статус: «{oldName}» → «{dto.AppStatusName}»");
 500
 501                // Запись в orders_history
 0502                _db.OrderHistories.Add(new OzonDomains.Models.OrderHistory
 0503                {
 0504                    OrderId          = orderId,
 0505                    ColumnName       = "AppStatusId",
 0506                    ColumnDisplayName = "Статус",
 0507                    OldValue         = oldName,
 0508                    NewValue         = dto.AppStatusName ?? dto.AppStatusId.Value.ToString(),
 0509                    ChangedAt        = DateTime.UtcNow,
 0510                    ChangedBy        = historyAuthor,
 0511                });
 512            }
 513
 0514            if (dto.SupplierId.HasValue && dto.SupplierId != order.SupplierId)
 515            {
 0516                var oldSupplierName = order.Supplier?.Name ?? "—";
 0517                order.SupplierId = dto.SupplierId.Value;
 0518                changedFields.Add($"Поставщик: «{oldSupplierName}» → «{dto.SupplierName}»");
 519
 0520                _db.OrderHistories.Add(new OzonDomains.Models.OrderHistory
 0521                {
 0522                    OrderId          = orderId,
 0523                    ColumnName       = "SupplierId",
 0524                    ColumnDisplayName = "Поставщик",
 0525                    OldValue         = oldSupplierName,
 0526                    NewValue         = dto.SupplierName ?? dto.SupplierId.Value.ToString(),
 0527                    ChangedAt        = DateTime.UtcNow,
 0528                    ChangedBy        = historyAuthor,
 0529                });
 530            }
 531
 0532            if (dto.ShipmentWarehouseId.HasValue && dto.ShipmentWarehouseId != order.ShipmentWarehouseId)
 533            {
 0534                var oldWarehouseName = order.ShipmentWarehouse?.Name ?? "—";
 0535                order.ShipmentWarehouseId = dto.ShipmentWarehouseId.Value;
 0536                changedFields.Add($"Склад: «{oldWarehouseName}» → «{dto.WarehouseName}»");
 537
 0538                _db.OrderHistories.Add(new OzonDomains.Models.OrderHistory
 0539                {
 0540                    OrderId          = orderId,
 0541                    ColumnName       = "ShipmentWarehouseId",
 0542                    ColumnDisplayName = "Склад отгрузки",
 0543                    OldValue         = oldWarehouseName,
 0544                    NewValue         = dto.WarehouseName ?? dto.ShipmentWarehouseId.Value.ToString(),
 0545                    ChangedAt        = DateTime.UtcNow,
 0546                    ChangedBy        = historyAuthor,
 0547                });
 548            }
 549
 0550            if (dto.PurchasePrice.HasValue && dto.PurchasePrice != order.CostPrice)
 551            {
 0552                var oldPrice = order.CostPrice;
 0553                order.CostPrice = dto.PurchasePrice.Value;
 0554                changedFields.Add($"Цена закупки: {oldPrice:N0} → {dto.PurchasePrice:N0} ₽");
 555            }
 556
 0557            if (dto.Quantity.HasValue && dto.Quantity != order.Quantity)
 558            {
 0559                var oldQty = order.Quantity;
 0560                order.Quantity = dto.Quantity.Value;
 0561                changedFields.Add($"Количество: {oldQty} → {dto.Quantity}");
 562            }
 563
 0564            if (!string.IsNullOrWhiteSpace(dto.OrderNumberToSupplier))
 565            {
 0566                order.OrderNumberToSupplier = dto.OrderNumberToSupplier;
 0567                changedFields.Add($"Номер заказа поставщику: {dto.OrderNumberToSupplier}");
 568            }
 569
 0570            if (changedFields.Count == 0)
 0571                return Fail("Нет изменений для сохранения.");
 572
 0573            order.UpdatedBy = userName;
 0574            await _db.SaveChangesAsync(ct);
 575
 576            // Пересчёт прибыли/наценки если изменена цена или поставщик
 0577            if (dto.PurchasePrice.HasValue || dto.SupplierId.HasValue)
 0578                await RecalcProfitAsync(orderId, ct);
 579
 0580            _logger.LogInformation("AI edit order #{Id}: {Changes}", orderId, string.Join(", ", changedFields));
 581
 0582            return new AiExecuteActionResponse
 0583            {
 0584                Success        = true,
 0585                Message        = $"Изменения сохранены:\n• {string.Join("\n• ", changedFields)}",
 0586                NewStatus      = dto.AppStatusName ?? order.AppStatus?.Name,
 0587                ShipmentNumber = order.ShipmentNumber,
 0588                OrderPageUrl   = $"/Orders/Index?ids={orderId}",
 0589                ManualPageUrl  = $"/Studio2/Orders/MultiplayEditV2?ids={orderId}&page=1",
 0590            };
 591        }
 0592        catch (Exception ex)
 593        {
 0594            _logger.LogError(ex, "AI edit order #{Id} failed", orderId);
 0595            return Fail($"Ошибка при сохранении: {ex.Message}");
 596        }
 0597    }
 598
 599    // ── Выполнение транзакции (только IntegrationMode = "none") ────────────────
 600
 601    public async Task<AiExecuteActionResponse> ExecuteTransactionAsync(
 602        int                    orderId,
 603        AiTransactionParamsDto dto,
 604        string                 userName,
 605        CancellationToken      ct,
 606        string?                model = null)
 607    {
 0608        if (!Enum.TryParse<TransactionType>(dto.TransactionTypeValue.ToString(), out var type))
 0609            return Fail("Неизвестный тип транзакции.");
 610
 0611        TransactionEndpoints.TryGetValue(type, out var endpoint);
 0612        var manualUrl = endpoint != null ? $"{endpoint}?ids={orderId}&page=1" : null;
 613
 614        // ShippedBySupplier выполняется агентом напрямую с опциональной 1С-проводкой
 0615        if (type == TransactionType.ShippedBySupplier)
 0616            return await ExecuteShippedBySupplierAsync(orderId, dto, userName, ct, model);
 617
 618        // ShippedToSeller выполняется агентом напрямую с опциональной 1С-проводкой и rollback
 0619        if (type == TransactionType.ShippedToSeller)
 0620            return await ExecuteShippedToSellerAsync(orderId, dto, userName, ct, model);
 621
 622        // ShippedToCommissioner — отгрузка на Ozon FBO с rollback
 0623        if (type == TransactionType.ShippedToCommissioner)
 0624            return await ExecuteShippedToCommissionerAsync(orderId, dto, userName, ct, model);
 625
 626        // Остальные транзакции с 1С — не выполняем автоматически, отправляем на страницу
 0627        if (!NoOneCTransactions.Contains(type))
 628        {
 0629            return new AiExecuteActionResponse
 0630            {
 0631                Success          = false,
 0632                Message          = $"Транзакция «{dto.TransactionLabel}» требует проводки в 1С. " +
 0633                                   "Пожалуйста, выполните её вручную по кнопке «Открыть страницу».",
 0634                ManualPageUrl    = manualUrl,
 0635                OrderPageUrl     = $"/Orders/Index?ids={orderId}",
 0636            };
 637        }
 638
 639        try
 640        {
 0641            var order = await _db.Orders
 0642                .Include(o => o.AppStatus)
 0643                .Include(o => o.Supplier)
 0644                .Include(o => o.ShipmentWarehouse)
 0645                .Include(o => o.ClientStatus)
 0646                .FirstOrDefaultAsync(o => o.Id == orderId, ct);
 647
 0648            if (order == null)
 0649                return Fail($"Заказ #{orderId} не найден.");
 650
 0651            var oldStatus   = order.AppStatus?.Name ?? "—";
 0652            var oldStatusId = order.AppStatusId;
 0653            var historyAuthor = FormatHistoryAuthor(userName, model);
 654
 655            // ── Применяем поля, переданные в TransactionDto ПЕРЕД валидацией ──
 656            // Цена закупки (если была указана пользователем при уточнении)
 0657            if (dto.PurchasePrice is > 0)
 658            {
 0659                var oldPrice = order.PurchasePrice ?? order.CostPrice ?? 0;
 0660                order.PurchasePrice = dto.PurchasePrice;
 0661                order.CostPrice     = dto.PurchasePrice;
 0662                _db.OrderHistories.Add(new OzonDomains.Models.OrderHistory
 0663                {
 0664                    OrderId           = orderId,
 0665                    ColumnName        = "PurchasePrice",
 0666                    ColumnDisplayName = "Цена закупки",
 0667                    OldValue          = oldPrice.ToString(),
 0668                    NewValue          = dto.PurchasePrice.Value.ToString(),
 0669                    ChangedAt         = DateTime.UtcNow,
 0670                    ChangedBy         = historyAuthor,
 0671                });
 672            }
 673
 674            // Поставщик (если был указан пользователем при уточнении)
 0675            if (dto.SupplierId is > 0 && dto.SupplierId != order.SupplierId)
 676            {
 0677                var oldSupplier = order.Supplier?.Name ?? "—";
 0678                order.SupplierId = dto.SupplierId;
 0679                order.Supplier   = null; // сбрасываем навигацию, чтобы валидация не видела старое "Не указан"
 0680                _db.OrderHistories.Add(new OzonDomains.Models.OrderHistory
 0681                {
 0682                    OrderId           = orderId,
 0683                    ColumnName        = "SupplierId",
 0684                    ColumnDisplayName = "Поставщик",
 0685                    OldValue          = oldSupplier,
 0686                    NewValue          = dto.SupplierName ?? dto.SupplierId.Value.ToString(),
 0687                    ChangedAt         = DateTime.UtcNow,
 0688                    ChangedBy         = historyAuthor,
 0689                });
 690            }
 691
 692            // ── Повторная валидация ПОСЛЕ применения dto-полей ────────────────
 0693            var preValidation = GetOrderLevelMissingFields(type, order);
 0694            if (preValidation.Count > 0)
 0695                return new AiExecuteActionResponse
 0696                {
 0697                    Success       = false,
 0698                    Message       = $"Транзакция заблокирована:\n• {string.Join("\n• ", preValidation)}",
 0699                    ManualPageUrl = manualUrl,
 0700                    OrderPageUrl  = $"/Orders/Index?ids={orderId}",
 0701                };
 702
 703            // Определяем целевой статус по типу транзакции
 0704            var targetStatusName = GetTargetStatus(type);
 0705            AppStatus? newStatus = null;
 0706            if (!string.IsNullOrWhiteSpace(targetStatusName))
 707            {
 0708                newStatus = await _db.AppStatuses.AsNoTracking()
 0709                    .FirstOrDefaultAsync(s => s.Name == targetStatusName, ct);
 0710                if (newStatus != null)
 711                {
 0712                    order.AppStatusId = newStatus.Id;
 0713                    order.LastStatusChangeDate = DateTime.Now;
 714
 0715                    _db.OrderHistories.Add(new OzonDomains.Models.OrderHistory
 0716                    {
 0717                        OrderId          = orderId,
 0718                        ColumnName       = "AppStatusId",
 0719                        ColumnDisplayName = "Статус",
 0720                        OldValue         = oldStatus,
 0721                        NewValue         = newStatus.Name,
 0722                        ChangedAt        = DateTime.UtcNow,
 0723                        ChangedBy        = historyAuthor,
 0724                    });
 725                }
 726            }
 727
 0728            order.IsAccepted = true;
 0729            order.UpdatedBy  = historyAuthor;
 0730            await _db.SaveChangesAsync(ct);
 731
 732            // Пересчёт прибыли/наценки (та же цепочка что в настоящей транзакции)
 0733            await RecalcProfitAsync(orderId, ct);
 734
 735            // Создаём запись транзакции
 0736            var (_, _, transactionId) = await CreateTransactionByTypeAsync(
 0737                type, [order], userName, DateTime.Now, dto.Comment ?? "AI-ассистент");
 738
 0739            _logger.LogInformation("AI transaction #{TxId} type={Type} order={OrderId}", transactionId, type, orderId);
 740
 0741            var statusChange = newStatus != null ? $" → «{newStatus.Name}»" : "";
 0742            return new AiExecuteActionResponse
 0743            {
 0744                Success             = true,
 0745                Message             = $"Транзакция «{dto.TransactionLabel}» создана (ID: {transactionId})." +
 0746                                      (statusChange != "" ? $"\nСтатус изменён: «{oldStatus}»{statusChange}" : ""),
 0747                NewStatus           = newStatus?.Name,
 0748                ShipmentNumber      = order.ShipmentNumber,
 0749                OrderPageUrl        = $"/Orders/Index?ids={orderId}",
 0750                TransactionPageUrl  = transactionId > 0 ? $"/Studio2/Transactions/Details/{transactionId}" : null,
 0751                ManualPageUrl       = manualUrl,
 0752            };
 753        }
 0754        catch (Exception ex)
 755        {
 0756            _logger.LogError(ex, "AI transaction type={Type} order=#{Id} failed", type, orderId);
 0757            return Fail($"Ошибка при создании транзакции: {ex.Message}");
 758        }
 0759    }
 760
 761    // ── Отгружен поставщиком: выполнение с опциональной 1С-проводкой ───────────
 762
 763    private async Task<AiExecuteActionResponse> ExecuteShippedBySupplierAsync(
 764        int                    orderId,
 765        AiTransactionParamsDto dto,
 766        string                 userName,
 767        CancellationToken      ct,
 768        string?                model = null)
 769    {
 0770        TransactionEndpoints.TryGetValue(TransactionType.ShippedBySupplier, out var endpoint);
 0771        var manualUrl = endpoint != null ? $"{endpoint}?ids={orderId}&page=1" : null;
 772
 773        try
 774        {
 0775            var order = await _db.Orders
 0776                .Include(o => o.AppStatus)
 0777                .Include(o => o.Supplier)
 0778                .Include(o => o.ShipmentWarehouse)
 0779                .Include(o => o.ClientStatus)
 0780                .Include(o => o.OzonClient)
 0781                .Include(o => o.OrderExtensions)
 0782                .FirstOrDefaultAsync(o => o.Id == orderId, ct);
 783
 0784            if (order == null)
 0785                return Fail($"Заказ #{orderId} не найден.");
 786
 0787            var oldStatus     = order.AppStatus?.Name ?? "—";
 0788            var historyAuthor = FormatHistoryAuthor(userName, model);
 789
 790            // Применяем цену если передана
 0791            if (dto.PurchasePrice is > 0)
 792            {
 0793                order.PurchasePrice = dto.PurchasePrice;
 0794                order.CostPrice     = dto.PurchasePrice;
 0795                _db.OrderHistories.Add(new OrderHistory
 0796                {
 0797                    OrderId           = orderId,
 0798                    ColumnName        = "PurchasePrice",
 0799                    ColumnDisplayName = "Цена закупки",
 0800                    OldValue          = (order.PurchasePrice ?? order.CostPrice ?? 0).ToString(),
 0801                    NewValue          = dto.PurchasePrice.Value.ToString(),
 0802                    ChangedAt         = DateTime.UtcNow,
 0803                    ChangedBy         = historyAuthor,
 0804                });
 805            }
 806
 807            // Повторная валидация
 0808            var preValidation = GetOrderLevelMissingFields(TransactionType.ShippedBySupplier, order);
 0809            if (preValidation.Count > 0)
 0810                return new AiExecuteActionResponse
 0811                {
 0812                    Success       = false,
 0813                    Message       = $"Транзакция заблокирована:\n• {string.Join("\n• ", preValidation)}",
 0814                    ManualPageUrl = manualUrl,
 0815                    OrderPageUrl  = $"/Orders/Index?ids={orderId}",
 0816                };
 817
 818            // Определяем эффективное количество
 0819            var qty = order.Quantity ?? 0;
 820
 821            // Смена статуса: Отгружен поставщиком (qty > 0) или Утерян поставщиком (qty <= 0)
 0822            var targetStatusName = qty > 0 ? "Отгружен поставщиком" : "Утерян поставщиком";
 0823            var newAppStatus = await _db.AppStatuses.AsNoTracking()
 0824                .FirstOrDefaultAsync(s => s.Name == targetStatusName, ct);
 825
 0826            if (newAppStatus != null)
 827            {
 0828                order.AppStatusId          = newAppStatus.Id;
 0829                order.LastStatusChangeDate = DateTime.Now;
 0830                _db.OrderHistories.Add(new OrderHistory
 0831                {
 0832                    OrderId           = orderId,
 0833                    ColumnName        = "AppStatusId",
 0834                    ColumnDisplayName = "Статус",
 0835                    OldValue          = oldStatus,
 0836                    NewValue          = newAppStatus.Name,
 0837                    ChangedAt         = DateTime.UtcNow,
 0838                    ChangedBy         = historyAuthor,
 0839                });
 840            }
 841
 0842            order.IsAccepted = true;
 0843            order.UpdatedBy  = historyAuthor;
 0844            await _db.SaveChangesAsync(ct);
 0845            await RecalcProfitAsync(orderId, ct);
 846
 847            // Создаём запись транзакции (только если qty > 0)
 0848            int transactionId = 0;
 0849            if (qty > 0)
 850            {
 0851                var (_, _, txId) = await _transactionManager.CreateShippedBySupplierTransaction(
 0852                    [order], userName, DateTime.Now, dto.Comment ?? "AI-ассистент");
 0853                transactionId = txId;
 854            }
 855
 0856            _logger.LogInformation("AI ShippedBySupplier tx=#{TxId} order=#{OrderId} processIn1C={Flag}",
 0857                transactionId, orderId, dto.ProcessIn1C);
 858
 859            // ── 1С-проводка ────────────────────────────────────────────────────
 0860            string? oneCResult = null;
 0861            if (dto.ProcessIn1C && qty > 0)
 862            {
 863                try
 864                {
 865                    // Перезагружаем заказ с полными навигациями для 1С
 0866                    var orderFor1C = await _db.Orders
 0867                        .Include(o => o.Supplier)
 0868                        .Include(o => o.OzonClient)
 0869                        .Include(o => o.OrderExtensions)
 0870                        .Include(o => o.Manufacturer)
 0871                        .Include(o => o.ProductInfo)
 0872                        .FirstOrDefaultAsync(o => o.Id == orderId, ct);
 873
 0874                    if (orderFor1C != null)
 875                    {
 0876                        var receiptDto = new ShippedBySupplierTransactionDto
 0877                        {
 0878                            ContractLink       = dto.ContractLink,
 0879                            NumberVh           = dto.NumberVh ?? string.Empty,
 0880                            DateVh             = dto.DateVh ?? DateTime.Today,
 0881                            VatRate            = dto.VatRate,
 0882                            TransactionComment = !string.IsNullOrWhiteSpace(dto.OneCComment)
 0883                                ? dto.OneCComment
 0884                                : $"Отгружен поставщиком (AI-ассистент: {userName})" +
 0885                                  (string.IsNullOrWhiteSpace(dto.Comment) ? "" : $". {dto.Comment}"),
 0886                        };
 887
 0888                        var responses = await _oneCReceiptManager.CreateReceipts(
 0889                            [orderFor1C], receiptDto, userName);
 890
 0891                        var successParts = responses
 0892                            .Where(r => r.Success)
 0893                            .Select(r =>
 0894                            {
 0895                                var num  = r.DocumentNumber != null ? $" №{r.DocumentNumber}" : "";
 0896                                var miss = !string.IsNullOrWhiteSpace(r.MissingNomenclature)
 0897                                    ? $" (черновик, не найдено: {r.MissingNomenclature})"
 0898                                    : (r.IsPosted ? " (проведена)" : " (черновик)");
 0899                                return $"✅ Накладная создана{num}{miss}";
 0900                            })
 0901                            .ToList();
 0902                        var failParts = responses
 0903                            .Where(r => !r.Success)
 0904                            .Select(r => $"⚠️ {r.Message}")
 0905                            .ToList();
 906
 0907                        oneCResult = string.Join("\n", successParts.Concat(failParts));
 0908                        if (string.IsNullOrWhiteSpace(oneCResult))
 0909                            oneCResult = "Результат из 1С не получен.";
 910                    }
 0911                }
 0912                catch (Exception ex1C)
 913                {
 0914                    _logger.LogWarning(ex1C, "AI ShippedBySupplier: 1C receipt failed for order #{Id} (non-blocking)", o
 0915                    oneCResult = $"⚠️ Ошибка при создании приходной накладной в 1С: {ex1C.Message}";
 0916                }
 917            }
 918
 0919            var statusInfo = newAppStatus != null ? $"«{oldStatus}» → «{newAppStatus.Name}»" : "";
 0920            var txInfo     = transactionId > 0 ? $" Транзакция #{transactionId}." : "";
 0921            var oneCInfo   = oneCResult != null ? $"\n\n**1С:** {oneCResult}" : "";
 922
 0923            return new AiExecuteActionResponse
 0924            {
 0925                Success            = true,
 0926                Message            = $"Транзакция «Отгружен поставщиком» создана.{txInfo}" +
 0927                                     (statusInfo != "" ? $"\nСтатус: {statusInfo}" : "") + oneCInfo,
 0928                NewStatus          = newAppStatus?.Name,
 0929                ShipmentNumber     = order.ShipmentNumber,
 0930                OrderPageUrl       = $"/Orders/Index?ids={orderId}",
 0931                TransactionPageUrl = transactionId > 0 ? $"/Studio2/Transactions/Details/{transactionId}" : null,
 0932                ManualPageUrl      = manualUrl,
 0933                OneCResult         = oneCResult,
 0934            };
 935        }
 0936        catch (Exception ex)
 937        {
 0938            _logger.LogError(ex, "AI ShippedBySupplier order=#{Id} failed", orderId);
 0939            return Fail($"Ошибка при создании транзакции: {ex.Message}");
 940        }
 0941    }
 942
 943    // ── Отгружен комиссионеру: отгрузка на Ozon FBO с опциональной 1С-проводкой и rollback ─
 944
 945    private async Task<AiExecuteActionResponse> ExecuteShippedToCommissionerAsync(
 946        int                    orderId,
 947        AiTransactionParamsDto dto,
 948        string                 userName,
 949        CancellationToken      ct,
 950        string?                model = null)
 951    {
 0952        TransactionEndpoints.TryGetValue(TransactionType.ShippedToCommissioner, out var endpoint);
 0953        var manualUrl = endpoint != null ? $"{endpoint}?ids={orderId}&page=1" : null;
 954
 955        try
 956        {
 0957            var order = await _db.Orders
 0958                .Include(o => o.AppStatus)
 0959                .Include(o => o.Supplier)
 0960                .Include(o => o.ShipmentWarehouse)
 0961                .Include(o => o.ClientStatus)
 0962                .Include(o => o.OzonClient)
 0963                .Include(o => o.OrderExtensions)
 0964                .FirstOrDefaultAsync(o => o.Id == orderId, ct);
 965
 0966            if (order == null)
 0967                return Fail($"Заказ #{orderId} не найден.");
 968
 0969            var oldStatus     = order.AppStatus?.Name ?? "—";
 0970            var oldStatusId   = order.AppStatusId;
 0971            var historyAuthor = FormatHistoryAuthor(userName, model);
 972
 973            // Применяем цену если передана
 0974            if (dto.PurchasePrice is > 0)
 975            {
 0976                order.PurchasePrice = dto.PurchasePrice;
 0977                order.CostPrice     = dto.PurchasePrice;
 0978                _db.OrderHistories.Add(new OrderHistory
 0979                {
 0980                    OrderId           = orderId,
 0981                    ColumnName        = "PurchasePrice",
 0982                    ColumnDisplayName = "Цена закупки",
 0983                    OldValue          = (order.PurchasePrice ?? order.CostPrice ?? 0).ToString(),
 0984                    NewValue          = dto.PurchasePrice.Value.ToString(),
 0985                    ChangedAt         = DateTime.UtcNow,
 0986                    ChangedBy         = historyAuthor,
 0987                });
 988            }
 989
 990            // Повторная валидация
 0991            var preValidation = GetOrderLevelMissingFields(TransactionType.ShippedToCommissioner, order);
 0992            if (preValidation.Count > 0)
 0993                return new AiExecuteActionResponse
 0994                {
 0995                    Success       = false,
 0996                    Message       = $"Транзакция заблокирована:\n• {string.Join("\n• ", preValidation)}",
 0997                    ManualPageUrl = manualUrl,
 0998                    OrderPageUrl  = $"/Orders/Index?ids={orderId}",
 0999                };
 1000
 1001            // Особая ветка: если текущий статус «Отгружен реализатором» —
 1002            // только смена статуса, без 1С и без записи Transaction
 01003            bool wasShippedToSeller = oldStatus == "Отгружен реализатором";
 1004
 01005            var newAppStatus = await _db.AppStatuses.AsNoTracking()
 01006                .FirstOrDefaultAsync(s => s.Name == "Отгружен комиссионеру", ct);
 1007
 01008            if (newAppStatus != null)
 1009            {
 01010                order.AppStatusId          = newAppStatus.Id;
 01011                order.LastStatusChangeDate = DateTime.Now;
 01012                _db.OrderHistories.Add(new OrderHistory
 01013                {
 01014                    OrderId           = orderId,
 01015                    ColumnName        = "AppStatusId",
 01016                    ColumnDisplayName = "Статус",
 01017                    OldValue          = oldStatus,
 01018                    NewValue          = newAppStatus.Name,
 01019                    ChangedAt         = DateTime.UtcNow,
 01020                    ChangedBy         = historyAuthor,
 01021                });
 1022            }
 1023
 01024            order.IsAccepted = true;
 01025            order.UpdatedBy  = historyAuthor;
 01026            await _db.SaveChangesAsync(ct);
 01027            await RecalcProfitAsync(orderId, ct);
 1028
 1029            // Если был «Отгружен реализатором» — только смена статуса
 01030            if (wasShippedToSeller)
 1031            {
 01032                _logger.LogInformation("AI ShippedToCommissioner (from ShippedToSeller) order=#{OrderId}: status-only", 
 01033                return new AiExecuteActionResponse
 01034                {
 01035                    Success        = true,
 01036                    Message        = $"Статус изменён: «{oldStatus}» → «Отгружен комиссионеру».\n" +
 01037                                     "Заказ был «Отгружен реализатором» — 1С не проводится (только смена статуса).",
 01038                    NewStatus      = newAppStatus?.Name,
 01039                    ShipmentNumber = order.ShipmentNumber,
 01040                    OrderPageUrl   = $"/Orders/Index?ids={orderId}",
 01041                    ManualPageUrl  = manualUrl,
 01042                    OneCTransferIcon = true,
 01043                };
 1044            }
 1045
 1046            // Обычный поток: создаём Transaction + опциональная 1С
 01047            int transactionId = 0;
 01048            string? oneCResult = null;
 1049
 01050            if (dto.ProcessIn1C)
 1051            {
 1052                try
 1053                {
 01054                    var orderFor1C = await _db.Orders
 01055                        .Include(o => o.Supplier)
 01056                        .Include(o => o.OzonClient)
 01057                        .Include(o => o.OrderExtensions)
 01058                        .Include(o => o.Manufacturer)
 01059                        .Include(o => o.ProductInfo)
 01060                        .Include(o => o.ShipmentWarehouse)
 01061                        .FirstOrDefaultAsync(o => o.Id == orderId, ct);
 1062
 01063                    if (orderFor1C != null)
 1064                    {
 01065                        var responses = await _oneCTransferManager.TransferStockByWarehousesWithReversedWarehouses(
 01066                            [orderFor1C],
 01067                            TransactionType.ShippedToCommissioner,
 01068                            documentDate: DateTime.Now,
 01069                            userName: userName);
 1070
 01071                        var hasFailure = responses.Any(r => !r.Success);
 01072                        if (hasFailure)
 1073                        {
 1074                            // Rollback
 01075                            var orderToRevert = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct);
 01076                            if (orderToRevert != null)
 1077                            {
 01078                                orderToRevert.AppStatusId          = oldStatusId;
 01079                                orderToRevert.LastStatusChangeDate = DateTime.Now;
 01080                                orderToRevert.IsAccepted           = false;
 01081                                orderToRevert.UpdatedBy            = historyAuthor;
 01082                                _db.OrderHistories.Add(new OrderHistory
 01083                                {
 01084                                    OrderId           = orderId,
 01085                                    ColumnName        = "AppStatusId",
 01086                                    ColumnDisplayName = "Статус",
 01087                                    OldValue          = "Отгружен комиссионеру",
 01088                                    NewValue          = oldStatus,
 01089                                    ChangedAt         = DateTime.UtcNow,
 01090                                    ChangedBy         = historyAuthor,
 01091                                });
 1092                            }
 01093                            await _db.SaveChangesAsync(ct);
 1094
 01095                            var failMessages = responses.Where(r => !r.Success).Select(r => r.Message ?? "Неизвестная ош
 01096                            return new AiExecuteActionResponse
 01097                            {
 01098                                Success       = false,
 01099                                Message       = $"Ошибка при проводке в 1С — транзакция отменена (rollback):\n• {string.
 01100                                ManualPageUrl = manualUrl,
 01101                                OrderPageUrl  = $"/Orders/Index?ids={orderId}",
 01102                            };
 1103                        }
 1104
 01105                        oneCResult = string.Join("\n", responses.Where(r => r.Success).Select(r =>
 01106                        {
 01107                            var num  = r.DocumentNumber != null ? $" №{r.DocumentNumber}" : "";
 01108                            var miss = !string.IsNullOrWhiteSpace(r.MissingNomenclature)
 01109                                ? $" (черновик, не найдено: {r.MissingNomenclature})"
 01110                                : (r.IsPosted ? " (проведено)" : " (черновик)");
 01111                            return $"✅ Перемещение создано{num}{miss}";
 01112                        }));
 01113                        if (string.IsNullOrWhiteSpace(oneCResult))
 01114                            oneCResult = "Результат из 1С не получен.";
 01115                    }
 01116                }
 01117                catch (Exception ex1C)
 1118                {
 01119                    _logger.LogWarning(ex1C, "AI ShippedToCommissioner: 1C transfer failed for order #{Id} (rollback)", 
 1120                    try
 1121                    {
 01122                        var orderToRevert = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct);
 01123                        if (orderToRevert != null)
 1124                        {
 01125                            orderToRevert.AppStatusId          = oldStatusId;
 01126                            orderToRevert.LastStatusChangeDate = DateTime.Now;
 01127                            orderToRevert.IsAccepted           = false;
 01128                            orderToRevert.UpdatedBy            = historyAuthor;
 1129                        }
 01130                        await _db.SaveChangesAsync(ct);
 01131                    }
 01132                    catch (Exception rollbackEx)
 1133                    {
 01134                        _logger.LogError(rollbackEx, "AI ShippedToCommissioner: rollback also failed for order #{Id}", o
 01135                    }
 1136
 01137                    return new AiExecuteActionResponse
 01138                    {
 01139                        Success       = false,
 01140                        Message       = $"Ошибка при создании перемещения в 1С — транзакция отменена (rollback): {ex1C.M
 01141                        ManualPageUrl = manualUrl,
 01142                        OrderPageUrl  = $"/Orders/Index?ids={orderId}",
 01143                    };
 1144                }
 1145            }
 1146
 1147            // Создаём запись транзакции
 01148            var (_, _, txId) = await _transactionManager.CreateShippedToCommissionerTransaction(
 01149                [order], userName, DateTime.Now, dto.Comment ?? "AI-ассистент");
 01150            transactionId = txId;
 1151
 01152            _logger.LogInformation("AI ShippedToCommissioner tx=#{TxId} order=#{OrderId} processIn1C={Flag}",
 01153                transactionId, orderId, dto.ProcessIn1C);
 1154
 01155            var txInfo   = transactionId > 0 ? $" Транзакция #{transactionId}." : "";
 01156            var oneCInfo = oneCResult != null ? $"\n\n**1С:** {oneCResult}" : "";
 1157
 01158            return new AiExecuteActionResponse
 01159            {
 01160                Success            = true,
 01161                Message            = $"Транзакция «Отгружен комиссионеру» создана.{txInfo}" +
 01162                                     $"\nСтатус: «{oldStatus}» → «Отгружен комиссионеру»" + oneCInfo,
 01163                NewStatus          = newAppStatus?.Name,
 01164                ShipmentNumber     = order.ShipmentNumber,
 01165                OrderPageUrl       = $"/Orders/Index?ids={orderId}",
 01166                TransactionPageUrl = transactionId > 0 ? $"/Studio2/Transactions/Details/{transactionId}" : null,
 01167                ManualPageUrl      = manualUrl,
 01168                OneCResult         = oneCResult,
 01169                OneCTransferIcon   = true,
 01170            };
 1171        }
 01172        catch (Exception ex)
 1173        {
 01174            _logger.LogError(ex, "AI ShippedToCommissioner order=#{Id} failed", orderId);
 01175            return Fail($"Ошибка при создании транзакции: {ex.Message}");
 1176        }
 01177    }
 1178
 1179    // ── Отгружен реализатором: выполнение с опциональной 1С-проводкой и rollback ─
 1180
 1181    private async Task<AiExecuteActionResponse> ExecuteShippedToSellerAsync(
 1182        int                    orderId,
 1183        AiTransactionParamsDto dto,
 1184        string                 userName,
 1185        CancellationToken      ct,
 1186        string?                model = null)
 1187    {
 01188        TransactionEndpoints.TryGetValue(TransactionType.ShippedToSeller, out var endpoint);
 01189        var manualUrl = endpoint != null ? $"{endpoint}?ids={orderId}&page=1" : null;
 1190
 1191        try
 1192        {
 01193            var order = await _db.Orders
 01194                .Include(o => o.AppStatus)
 01195                .Include(o => o.Supplier)
 01196                .Include(o => o.ShipmentWarehouse)
 01197                .Include(o => o.ClientStatus)
 01198                .Include(o => o.OzonClient)
 01199                .Include(o => o.OrderExtensions)
 01200                .FirstOrDefaultAsync(o => o.Id == orderId, ct);
 1201
 01202            if (order == null)
 01203                return Fail($"Заказ #{orderId} не найден.");
 1204
 01205            var oldStatus     = order.AppStatus?.Name ?? "—";
 01206            var oldStatusId   = order.AppStatusId;
 01207            var historyAuthor = FormatHistoryAuthor(userName, model);
 1208
 1209            // Применяем цену если передана
 01210            if (dto.PurchasePrice is > 0)
 1211            {
 01212                order.PurchasePrice = dto.PurchasePrice;
 01213                order.CostPrice     = dto.PurchasePrice;
 01214                _db.OrderHistories.Add(new OrderHistory
 01215                {
 01216                    OrderId           = orderId,
 01217                    ColumnName        = "PurchasePrice",
 01218                    ColumnDisplayName = "Цена закупки",
 01219                    OldValue          = (order.PurchasePrice ?? order.CostPrice ?? 0).ToString(),
 01220                    NewValue          = dto.PurchasePrice.Value.ToString(),
 01221                    ChangedAt         = DateTime.UtcNow,
 01222                    ChangedBy         = historyAuthor,
 01223                });
 1224            }
 1225
 1226            // Повторная валидация
 01227            var preValidation = GetOrderLevelMissingFields(TransactionType.ShippedToSeller, order);
 01228            if (preValidation.Count > 0)
 01229                return new AiExecuteActionResponse
 01230                {
 01231                    Success       = false,
 01232                    Message       = $"Транзакция заблокирована:\n• {string.Join("\n• ", preValidation)}",
 01233                    ManualPageUrl = manualUrl,
 01234                    OrderPageUrl  = $"/Orders/Index?ids={orderId}",
 01235                };
 1236
 01237            var qty = order.Quantity ?? 0;
 01238            bool isLost = qty <= 0;
 1239
 1240            // Определяем целевой статус
 01241            var targetStatusName = isLost ? "Утерян реализатором" : "Отгружен реализатором";
 01242            var newAppStatus = await _db.AppStatuses.AsNoTracking()
 01243                .FirstOrDefaultAsync(s => s.Name == targetStatusName, ct);
 1244
 01245            if (newAppStatus != null)
 1246            {
 01247                order.AppStatusId          = newAppStatus.Id;
 01248                order.LastStatusChangeDate = DateTime.Now;
 01249                _db.OrderHistories.Add(new OrderHistory
 01250                {
 01251                    OrderId           = orderId,
 01252                    ColumnName        = "AppStatusId",
 01253                    ColumnDisplayName = "Статус",
 01254                    OldValue          = oldStatus,
 01255                    NewValue          = newAppStatus.Name,
 01256                    ChangedAt         = DateTime.UtcNow,
 01257                    ChangedBy         = historyAuthor,
 01258                });
 1259            }
 1260
 01261            order.IsAccepted = true;
 01262            order.UpdatedBy  = historyAuthor;
 01263            await _db.SaveChangesAsync(ct);
 01264            await RecalcProfitAsync(orderId, ct);
 1265
 1266            // Создаём запись транзакции
 01267            int transactionId = 0;
 01268            if (isLost)
 1269            {
 01270                var (_, _, txId) = await _transactionManager.CreateLostByCommissionerTransaction(
 01271                    [order], userName, DateTime.Now, dto.Comment ?? "AI-ассистент");
 01272                transactionId = txId;
 1273            }
 1274            else
 1275            {
 01276                var (_, _, txId) = await _transactionManager.CreateShippedToSellerTransaction(
 01277                    [order], userName, DateTime.Now, dto.Comment ?? "AI-ассистент");
 01278                transactionId = txId;
 1279            }
 1280
 01281            _logger.LogInformation("AI ShippedToSeller tx=#{TxId} order=#{OrderId} isLost={IsLost} processIn1C={Flag}",
 01282                transactionId, orderId, isLost, dto.ProcessIn1C);
 1283
 1284            // ── 1С-проводка (всегда, с rollback при ошибке) ───────────────────
 01285            string? oneCResult = null;
 1286            {
 1287                try
 1288                {
 01289                    var orderFor1C = await _db.Orders
 01290                        .Include(o => o.Supplier)
 01291                        .Include(o => o.OzonClient)
 01292                        .Include(o => o.OrderExtensions)
 01293                        .Include(o => o.Manufacturer)
 01294                        .Include(o => o.ProductInfo)
 01295                        .Include(o => o.ShipmentWarehouse)
 01296                        .FirstOrDefaultAsync(o => o.Id == orderId, ct);
 1297
 01298                    if (orderFor1C != null)
 1299                    {
 1300                        List<OneCResponse> responses;
 01301                        if (isLost)
 1302                        {
 01303                            responses = await _oneCTransferManager.TransferStockToTransitWarehouses(
 01304                                [orderFor1C],
 01305                                documentDate: DateTime.Now,
 01306                                userName: userName);
 1307                        }
 1308                        else
 1309                        {
 01310                            responses = await _oneCTransferManager.TransferStockByWarehouses(
 01311                                [orderFor1C],
 01312                                TransactionType.ShippedToSeller,
 01313                                documentDate: DateTime.Now,
 01314                                userName: userName);
 1315                        }
 1316
 01317                        var hasFailure = responses.Any(r => !r.Success);
 01318                        if (hasFailure)
 1319                        {
 1320                            // Rollback: восстанавливаем статус и удаляем транзакцию
 01321                            var orderToRevert = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct);
 01322                            if (orderToRevert != null)
 1323                            {
 01324                                orderToRevert.AppStatusId          = oldStatusId;
 01325                                orderToRevert.LastStatusChangeDate = DateTime.Now;
 01326                                orderToRevert.IsAccepted           = false;
 01327                                orderToRevert.UpdatedBy            = historyAuthor;
 01328                                _db.OrderHistories.Add(new OrderHistory
 01329                                {
 01330                                    OrderId           = orderId,
 01331                                    ColumnName        = "AppStatusId",
 01332                                    ColumnDisplayName = "Статус",
 01333                                    OldValue          = targetStatusName,
 01334                                    NewValue          = oldStatus,
 01335                                    ChangedAt         = DateTime.UtcNow,
 01336                                    ChangedBy         = historyAuthor,
 01337                                });
 1338                            }
 1339
 01340                            if (transactionId > 0)
 1341                            {
 01342                                var txType = isLost ? "LostByCommissioner" : "ShippedToSeller";
 01343                                var txRecord = await _db.Transactions
 01344                                    .FirstOrDefaultAsync(t => t.Id == transactionId, ct);
 01345                                if (txRecord != null)
 01346                                    _db.Transactions.Remove(txRecord);
 01347                                _logger.LogWarning("AI ShippedToSeller: 1C failed, rolling back tx=#{TxId} ({Type}) orde
 01348                                    transactionId, txType, orderId);
 01349                            }
 1350
 01351                            await _db.SaveChangesAsync(ct);
 1352
 01353                            var failMessages = responses
 01354                                .Where(r => !r.Success)
 01355                                .Select(r => r.Message ?? "Неизвестная ошибка");
 01356                            return new AiExecuteActionResponse
 01357                            {
 01358                                Success       = false,
 01359                                Message       = $"Ошибка при проводке в 1С — транзакция отменена (rollback):\n• {string.
 01360                                ManualPageUrl = manualUrl,
 01361                                OrderPageUrl  = $"/Orders/Index?ids={orderId}",
 01362                            };
 1363                        }
 1364
 01365                        var successParts = responses
 01366                            .Where(r => r.Success)
 01367                            .Select(r =>
 01368                            {
 01369                                var num  = r.DocumentNumber != null ? $" №{r.DocumentNumber}" : "";
 01370                                var miss = !string.IsNullOrWhiteSpace(r.MissingNomenclature)
 01371                                    ? $" (черновик, не найдено: {r.MissingNomenclature})"
 01372                                    : (r.IsPosted ? " (проведено)" : " (черновик)");
 01373                                return $"✅ Перемещение создано{num}{miss}";
 01374                            });
 01375                        oneCResult = string.Join("\n", successParts);
 01376                        if (string.IsNullOrWhiteSpace(oneCResult))
 01377                            oneCResult = "Результат из 1С не получен.";
 01378                    }
 01379                }
 01380                catch (Exception ex1C)
 1381                {
 01382                    _logger.LogWarning(ex1C, "AI ShippedToSeller: 1C transfer failed for order #{Id} (rollback)", orderI
 1383
 1384                    // Rollback при исключении
 1385                    try
 1386                    {
 01387                        var orderToRevert = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct);
 01388                        if (orderToRevert != null)
 1389                        {
 01390                            orderToRevert.AppStatusId          = oldStatusId;
 01391                            orderToRevert.LastStatusChangeDate = DateTime.Now;
 01392                            orderToRevert.IsAccepted           = false;
 01393                            orderToRevert.UpdatedBy            = historyAuthor;
 1394                        }
 01395                        if (transactionId > 0)
 1396                        {
 01397                            var txRecord = await _db.Transactions.FirstOrDefaultAsync(t => t.Id == transactionId, ct);
 01398                            if (txRecord != null)
 01399                                _db.Transactions.Remove(txRecord);
 1400                        }
 01401                        await _db.SaveChangesAsync(ct);
 01402                    }
 01403                    catch (Exception rollbackEx)
 1404                    {
 01405                        _logger.LogError(rollbackEx, "AI ShippedToSeller: rollback also failed for order #{Id}", orderId
 01406                    }
 1407
 01408                    return new AiExecuteActionResponse
 01409                    {
 01410                        Success       = false,
 01411                        Message       = $"Ошибка при создании перемещения в 1С — транзакция отменена (rollback): {ex1C.M
 01412                        ManualPageUrl = manualUrl,
 01413                        OrderPageUrl  = $"/Orders/Index?ids={orderId}",
 01414                    };
 1415                }
 1416            }
 1417
 01418            var txLabel    = isLost ? "Утерян реализатором" : "Отгружен реализатором";
 01419            var statusInfo = newAppStatus != null ? $"«{oldStatus}» → «{newAppStatus.Name}»" : "";
 01420            var txInfo     = transactionId > 0 ? $" Транзакция #{transactionId}." : "";
 01421            var oneCInfo   = oneCResult != null ? $"\n\n**1С:** {oneCResult}" : "";
 1422
 01423            return new AiExecuteActionResponse
 01424            {
 01425                Success            = true,
 01426                Message            = $"Транзакция «{txLabel}» создана.{txInfo}" +
 01427                                     (statusInfo != "" ? $"\nСтатус: {statusInfo}" : "") + oneCInfo,
 01428                NewStatus          = newAppStatus?.Name,
 01429                ShipmentNumber     = order.ShipmentNumber,
 01430                OrderPageUrl       = $"/Orders/Index?ids={orderId}",
 01431                TransactionPageUrl = transactionId > 0 ? $"/Studio2/Transactions/Details/{transactionId}" : null,
 01432                ManualPageUrl      = manualUrl,
 01433                OneCResult         = oneCResult,
 01434                OneCTransferIcon   = true,
 01435            };
 1436        }
 01437        catch (Exception ex)
 1438        {
 01439            _logger.LogError(ex, "AI ShippedToSeller order=#{Id} failed", orderId);
 01440            return Fail($"Ошибка при создании транзакции: {ex.Message}");
 1441        }
 01442    }
 1443
 1444    // ── Вспомогательные методы ─────────────────────────────────────────────────
 1445
 1446    private async Task<Order?> LoadOrderAsync(int orderId, string? userId, CancellationToken ct)
 1447    {
 01448        var query = _db.Orders.AsNoTracking()
 01449            .Include(o => o.AppStatus)
 01450            .Include(o => o.Supplier)
 01451            .Include(o => o.ShipmentWarehouse)
 01452            .Include(o => o.ClientStatus)
 01453            .Where(o => o.Id == orderId);
 1454
 01455        query = await _scopeApplier.ApplyAsync(query, userId, ct);
 01456        return await query.FirstOrDefaultAsync(ct);
 01457    }
 1458
 1459    private async Task<AppStatus?> FindStatusFuzzyAsync(string name, CancellationToken ct)
 1460    {
 01461        var statuses = await _appStatuses.GetAppStatuses();
 01462        return statuses.FirstOrDefault(s =>
 01463            s.Name != null &&
 01464            (s.Name.Equals(name, StringComparison.OrdinalIgnoreCase) ||
 01465             s.Name.Contains(name, StringComparison.OrdinalIgnoreCase)));
 01466    }
 1467
 1468    private async Task<OzonDomains.Models.Supplier?> FindSupplierFuzzyAsync(string name, CancellationToken ct)
 1469    {
 01470        var suppliers = await _suppliers.GetSuppliers();
 01471        return suppliers.FirstOrDefault(s =>
 01472            s.Name != null &&
 01473            (s.Name.Equals(name, StringComparison.OrdinalIgnoreCase) ||
 01474             s.Name.Contains(name, StringComparison.OrdinalIgnoreCase)));
 01475    }
 1476
 1477    private async Task<OzonDomains.Models.Warehouse?> FindWarehouseFuzzyAsync(string name, CancellationToken ct)
 1478    {
 01479        var warehouses = await _warehouses.GetWarehouses();
 01480        return warehouses.FirstOrDefault(w =>
 01481            w.Name != null &&
 01482            (w.Name.Equals(name, StringComparison.OrdinalIgnoreCase) ||
 01483             w.Name.Contains(name, StringComparison.OrdinalIgnoreCase)));
 01484    }
 1485
 01486    private static string? GetTargetStatus(TransactionType type) => type switch
 01487    {
 01488        TransactionType.OrderedToSupplier   => "Заказан поставщику",
 01489        TransactionType.OrderedToSeller     => "Заказан реализатору",
 01490        TransactionType.AcceptedToWarehouse => "ПринятНаСкладе",
 01491        TransactionType.ToCancellation      => "К отмене",
 01492        TransactionType.Cancelled           => "Отменен",
 01493        TransactionType.WarehouseChange     => null, // не меняет статус
 01494        TransactionType.ReturnTransfer      => "Перемещение",
 01495        _                                   => null
 01496    };
 1497
 1498    private Task<(int, string, int)> CreateTransactionByTypeAsync(
 1499        TransactionType type,
 1500        List<Order>     orders,
 1501        string          userName,
 1502        DateTime        createDateTime,
 01503        string          comment) => type switch
 01504    {
 01505        TransactionType.OrderedToSupplier   => _transactionManager.CreateOrderToSupplierTransaction(orders, userName, cr
 01506        TransactionType.OrderedToSeller     => _transactionManager.CreateOrderedToSellerTransaction(orders, userName, cr
 01507        TransactionType.ToCancellation      => _transactionManager.CreateToCancellationTransaction(orders, userName, cre
 01508        TransactionType.Cancelled           => _transactionManager.CreateCancelledTransaction(orders, userName, createDa
 01509        TransactionType.WarehouseChange     => _transactionManager.CreateWarehouseChangeTransaction(orders, userName, cr
 01510        TransactionType.ReturnTransfer      => _transactionManager.CreateReturnTransferTransaction(orders, userName, cre
 01511        TransactionType.Percentage          => _transactionManager.CreatePercentageTransaction(orders, userName, createD
 01512        // Для транзакций с 1С (не должны доходить до этого метода, но на всякий случай):
 01513        TransactionType.AcceptedToWarehouse => _transactionManager.AcceptedToWarehouseTransaction(orders, userName, crea
 01514        TransactionType.WarehouseTransfer   => _transactionManager.CreateWarehouseTransferTransaction(orders, userName, 
 01515        _                                   => throw new InvalidOperationException($"Transaction type {type} not support
 01516    };
 1517
 1518    /// <summary>
 1519    /// Форматирует строку автора для orders_history:
 1520    /// "Иван Иванов [AI Стас: deepseek/deepseek-v4-flash]"
 1521    /// </summary>
 1522    private static string FormatHistoryAuthor(string userName, string? model)
 1523    {
 01524        if (string.IsNullOrWhiteSpace(model))
 01525            return $"{userName} [AI Стас]";
 1526        // Укорачиваем длинный ID модели: берём только часть после "/"
 01527        var shortModel = model.Contains('/') ? model[(model.LastIndexOf('/') + 1)..] : model;
 01528        return $"{userName} [AI Стас: {shortModel}]";
 1529    }
 1530
 1531    /// <summary>
 1532    /// Пересчитывает MinProfit/MaxProfit/MinDiscount/MaxDiscount для заказа —
 1533    /// та же цепочка, что в CastToModel (настоящие транзакции).
 1534    /// Перезагружает заказ со всеми нужными Include, затем сохраняет пересчитанные поля.
 1535    /// </summary>
 1536    private async Task RecalcProfitAsync(int orderId, CancellationToken ct)
 1537    {
 1538        try
 1539        {
 01540            var order = await _db.Orders
 01541                .Include(o => o.Supplier)
 01542                .Include(o => o.ProductInfo)
 01543                .FirstOrDefaultAsync(o => o.Id == orderId, ct);
 1544
 01545            if (order == null) return;
 1546
 01547            order = await _priceManager.SetPurchasePriceToRUB(order) ?? order;
 01548            order = await _priceManager.CalculateCostPrice(order)    ?? order;
 01549            order = await _priceManager.CalculateProfit(order)       ?? order;
 01550            order = await _priceManager.CalculateDiscount(order)     ?? order;
 1551
 01552            await _db.SaveChangesAsync(ct);
 01553        }
 01554        catch (Exception ex)
 1555        {
 01556            _logger.LogWarning(ex, "AI: recalc profit failed for order #{Id} (non-blocking)", orderId);
 01557        }
 01558    }
 1559
 1560    private static AiExecuteActionResponse Fail(string message) =>
 01561        new() { Success = false, Message = message };
 1562}