| | | 1 | | using Microsoft.EntityFrameworkCore; |
| | | 2 | | using Microsoft.Extensions.Logging; |
| | | 3 | | using OzonDomains; |
| | | 4 | | using OzonDomains.Models; |
| | | 5 | | using OzonRepositories.Context; |
| | | 6 | | using Services.ApiServices._1CApi; |
| | | 7 | | using Services.ApiServices._1CApi.DTO; |
| | | 8 | | using Services.ApiServices._1CApi.Models; |
| | | 9 | | using Services.ApiServices.OpenRouterApi.Models; |
| | | 10 | | using Services.DataServices; |
| | | 11 | | using Services.PriceСlculationServices; |
| | | 12 | | using Services.TransactionUtilsServices; |
| | | 13 | | |
| | | 14 | | namespace Services.AiAssistant; |
| | | 15 | | |
| | | 16 | | /// <summary> |
| | | 17 | | /// Строит предложения write-действий (proposal) и выполняет их по запросу из AI-контроллера. |
| | | 18 | | /// </summary> |
| | | 19 | | public 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) |
| | 0 | 33 | | private static readonly Dictionary<TransactionType, string> TransactionEndpoints = new() |
| | 0 | 34 | | { |
| | 0 | 35 | | [TransactionType.OrderedToSupplier] = "/Studio2/OrderToSupplierTransaction/CreateOrderToSupplierTransactionV2" |
| | 0 | 36 | | [TransactionType.OrderedToSeller] = "/Studio2/OrderedToSellerTransaction/CreateOrderedToSellerTransaction", |
| | 0 | 37 | | [TransactionType.ShippedBySupplier] = "/Studio2/ShippedBySupplierTransaction/CreateShippedBySupplierTransactio |
| | 0 | 38 | | [TransactionType.ShippedToSeller] = "/Studio2/ShippedToSellerTransaction/CreateShippedToSellerTransaction", |
| | 0 | 39 | | [TransactionType.AcceptedToWarehouse] = "/Studio2/AcceptedToWarehouseTransaction/AcceptedToWarehouseTransaction" |
| | 0 | 40 | | [TransactionType.ShippedToClient] = "/Studio2/ShippedToClientTransaction/CreateShippedToClientTransaction", |
| | 0 | 41 | | [TransactionType.CustomerReturn] = "/Studio2/CustomerReturnTransaction/CreateCustomerReturnTransaction", |
| | 0 | 42 | | [TransactionType.ReturnToSupplier] = "/Studio2/ReturnToSupplierTransaction/CreateReturnToSupplierTransaction" |
| | 0 | 43 | | [TransactionType.ReturnToWarehouse] = "/Studio2/ReturnToWarehouseTransaction/CreateReturnToWarehouseTransactio |
| | 0 | 44 | | [TransactionType.WarehouseChange] = "/Studio2/WarehouseChangeTransaction/CreateWarehouseChangeTransaction", |
| | 0 | 45 | | [TransactionType.WarehouseTransfer] = "/Studio2/WarehouseTransferTransaction/CreateWarehouseTransferTransactio |
| | 0 | 46 | | [TransactionType.ShippedToCommissioner] = "/Studio2/ShippedToCommissionerTransaction/CreateShippedToCommissioner |
| | 0 | 47 | | [TransactionType.ToCancellation] = "/Studio2/ToCancellationTransaction/CreateToCancellationTransaction", |
| | 0 | 48 | | [TransactionType.Cancelled] = "/Studio2/CancelledTransaction/CreateCancelledTransaction", |
| | 0 | 49 | | [TransactionType.ReturnTransfer] = "/Studio2/ReturnTransferTransaction/CreateReturnTransferTransaction", |
| | 0 | 50 | | [TransactionType.LostByCommissioner] = "/Studio2/LostByCommissionerTransaction/CreateLostByCommissionerTransact |
| | 0 | 51 | | }; |
| | | 52 | | |
| | | 53 | | // Транзакции, не требующие 1С (IntegrationMode = "none") |
| | 0 | 54 | | private static readonly HashSet<TransactionType> NoOneCTransactions = new() |
| | 0 | 55 | | { |
| | 0 | 56 | | TransactionType.OrderedToSupplier, |
| | 0 | 57 | | TransactionType.OrderedToSeller, |
| | 0 | 58 | | TransactionType.AcceptedToWarehouse, // только фиксация приёма, 1С вне этой формы |
| | 0 | 59 | | TransactionType.ToCancellation, |
| | 0 | 60 | | TransactionType.Cancelled, |
| | 0 | 61 | | TransactionType.WarehouseChange, |
| | 0 | 62 | | TransactionType.ReturnTransfer, |
| | 0 | 63 | | TransactionType.Percentage, |
| | 0 | 64 | | }; |
| | | 65 | | |
| | | 66 | | // Для транзакций с 1С указываем тип документа |
| | 0 | 67 | | private static readonly Dictionary<TransactionType, string> OneCDocumentTypes = new() |
| | 0 | 68 | | { |
| | 0 | 69 | | [TransactionType.ShippedBySupplier] = "Приходная накладная (receipt)", |
| | 0 | 70 | | [TransactionType.ShippedToSeller] = "Перемещение (transfer)", |
| | 0 | 71 | | [TransactionType.ShippedToClient] = "Перемещение + расходная накладная (transfer+expense)", |
| | 0 | 72 | | [TransactionType.CustomerReturn] = "Перемещение (transfer)", |
| | 0 | 73 | | [TransactionType.ReturnToSupplier] = "Перемещение (transfer)", |
| | 0 | 74 | | [TransactionType.ReturnToWarehouse] = "Перемещение (transfer)", |
| | 0 | 75 | | [TransactionType.WarehouseTransfer] = "Перемещение (transfer)", |
| | 0 | 76 | | [TransactionType.ShippedToCommissioner] = "Перемещение (transfer)", |
| | 0 | 77 | | [TransactionType.LostByCommissioner] = "Перемещение (transfer)", |
| | 0 | 78 | | }; |
| | | 79 | | |
| | 0 | 80 | | public AiOrderWriteService( |
| | 0 | 81 | | ApplicationDbContext db, |
| | 0 | 82 | | AiOrderScopeApplier scopeApplier, |
| | 0 | 83 | | AppStatusDataServices appStatuses, |
| | 0 | 84 | | SupplierDataServices suppliers, |
| | 0 | 85 | | WarehouseDataServices warehouses, |
| | 0 | 86 | | TransactionManager transactionManager, |
| | 0 | 87 | | OrderPriceManager priceManager, |
| | 0 | 88 | | OneCReceiptManager oneCReceiptManager, |
| | 0 | 89 | | OneCTransferManager oneCTransferManager, |
| | 0 | 90 | | ILogger<AiOrderWriteService> logger) |
| | | 91 | | { |
| | 0 | 92 | | _db = db; |
| | 0 | 93 | | _scopeApplier = scopeApplier; |
| | 0 | 94 | | _appStatuses = appStatuses; |
| | 0 | 95 | | _suppliers = suppliers; |
| | 0 | 96 | | _warehouses = warehouses; |
| | 0 | 97 | | _transactionManager = transactionManager; |
| | 0 | 98 | | _priceManager = priceManager; |
| | 0 | 99 | | _oneCReceiptManager = oneCReceiptManager; |
| | 0 | 100 | | _oneCTransferManager = oneCTransferManager; |
| | 0 | 101 | | _logger = logger; |
| | 0 | 102 | | } |
| | | 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 | | { |
| | 0 | 116 | | var order = await LoadOrderAsync(orderId, userId, ct); |
| | 0 | 117 | | if (order == null) return null; |
| | | 118 | | |
| | 0 | 119 | | var dto = new AiOrderEditDto(); |
| | 0 | 120 | | var missing = new List<string>(); |
| | | 121 | | |
| | | 122 | | // Пытаемся извлечь каждое поле из сообщения |
| | 0 | 123 | | var supplierName = AiWriteIntentParser.TryExtractSupplierName(userMessage); |
| | 0 | 124 | | var statusName = AiWriteIntentParser.TryExtractStatusName(userMessage); |
| | 0 | 125 | | var warehouseName = AiWriteIntentParser.TryExtractWarehouseName(userMessage); |
| | 0 | 126 | | var price = AiWriteIntentParser.TryExtractPrice(userMessage); |
| | 0 | 127 | | var quantity = AiWriteIntentParser.TryExtractQuantity(userMessage); |
| | | 128 | | |
| | 0 | 129 | | var lower = userMessage.ToLowerInvariant(); |
| | 0 | 130 | | bool wantsSupplier = lower.Contains("поставщик"); |
| | 0 | 131 | | bool wantsStatus = lower.Contains("статус"); |
| | 0 | 132 | | bool wantsWarehouse = lower.Contains("склад"); |
| | 0 | 133 | | bool wantsPrice = lower.Contains("цен"); |
| | 0 | 134 | | bool wantsQuantity = lower.Contains("количеств"); |
| | | 135 | | |
| | 0 | 136 | | if (wantsSupplier) |
| | | 137 | | { |
| | 0 | 138 | | if (!string.IsNullOrWhiteSpace(supplierName)) |
| | | 139 | | { |
| | 0 | 140 | | var supplier = await FindSupplierFuzzyAsync(supplierName, ct); |
| | 0 | 141 | | if (supplier != null) |
| | | 142 | | { |
| | 0 | 143 | | dto.SupplierId = supplier.Id; |
| | 0 | 144 | | dto.SupplierName = supplier.Name; |
| | | 145 | | } |
| | | 146 | | else |
| | 0 | 147 | | missing.Add($"поставщик «{supplierName}» не найден в базе — уточните название"); |
| | | 148 | | } |
| | | 149 | | else |
| | 0 | 150 | | missing.Add("имя поставщика (укажите, например: «поставщик Interparts»)"); |
| | | 151 | | } |
| | | 152 | | |
| | 0 | 153 | | if (wantsStatus) |
| | | 154 | | { |
| | 0 | 155 | | if (!string.IsNullOrWhiteSpace(statusName)) |
| | | 156 | | { |
| | 0 | 157 | | var status = await FindStatusFuzzyAsync(statusName, ct); |
| | 0 | 158 | | if (status != null) |
| | | 159 | | { |
| | 0 | 160 | | dto.AppStatusId = status.Id; |
| | 0 | 161 | | dto.AppStatusName = status.Name; |
| | | 162 | | } |
| | | 163 | | else |
| | 0 | 164 | | missing.Add($"статус «{statusName}» не найден в базе — уточните название"); |
| | | 165 | | } |
| | | 166 | | else |
| | 0 | 167 | | missing.Add("название нового статуса"); |
| | | 168 | | } |
| | | 169 | | |
| | 0 | 170 | | if (wantsWarehouse) |
| | | 171 | | { |
| | 0 | 172 | | if (!string.IsNullOrWhiteSpace(warehouseName)) |
| | | 173 | | { |
| | 0 | 174 | | var warehouse = await FindWarehouseFuzzyAsync(warehouseName, ct); |
| | 0 | 175 | | if (warehouse != null) |
| | | 176 | | { |
| | 0 | 177 | | dto.ShipmentWarehouseId = warehouse.Id; |
| | 0 | 178 | | dto.WarehouseName = warehouse.Name; |
| | | 179 | | } |
| | | 180 | | else |
| | 0 | 181 | | missing.Add($"склад «{warehouseName}» не найден в базе — уточните название"); |
| | | 182 | | } |
| | | 183 | | else |
| | 0 | 184 | | missing.Add("название склада"); |
| | | 185 | | } |
| | | 186 | | |
| | 0 | 187 | | if (wantsPrice) |
| | | 188 | | { |
| | 0 | 189 | | if (price.HasValue) |
| | 0 | 190 | | dto.PurchasePrice = price; |
| | | 191 | | else |
| | 0 | 192 | | missing.Add("новая цена закупки (число в рублях)"); |
| | | 193 | | } |
| | | 194 | | |
| | 0 | 195 | | if (wantsQuantity) |
| | | 196 | | { |
| | 0 | 197 | | if (quantity.HasValue) |
| | 0 | 198 | | dto.Quantity = quantity; |
| | | 199 | | else |
| | 0 | 200 | | missing.Add("новое количество (целое число)"); |
| | | 201 | | } |
| | | 202 | | |
| | | 203 | | // Нет ни одного поля — не распознали запрос |
| | 0 | 204 | | if (!wantsSupplier && !wantsStatus && !wantsWarehouse && !wantsPrice && !wantsQuantity) |
| | 0 | 205 | | return null; |
| | | 206 | | |
| | 0 | 207 | | return new AiActionProposalDto |
| | 0 | 208 | | { |
| | 0 | 209 | | Kind = "edit_order", |
| | 0 | 210 | | OrderId = orderId, |
| | 0 | 211 | | ShipmentNumber = order.ShipmentNumber, |
| | 0 | 212 | | CurrentStatus = order.AppStatus?.Name, |
| | 0 | 213 | | EditDto = dto, |
| | 0 | 214 | | ManualPageUrl = $"/Studio2/Orders/MultiplayEditV2?ids={orderId}&page=1", |
| | 0 | 215 | | MissingFields = missing.Count > 0 ? missing : null, |
| | 0 | 216 | | }; |
| | 0 | 217 | | } |
| | | 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 | | { |
| | 0 | 228 | | var order = await LoadOrderAsync(orderId, userId, ct); |
| | 0 | 229 | | if (order == null) return null; |
| | | 230 | | |
| | | 231 | | // ── Извлекаем данные из сообщения (цена, поставщик) ────────────────── |
| | 0 | 232 | | var msgPrice = AiWriteIntentParser.TryExtractPrice(userMessage); |
| | 0 | 233 | | var msgSupplier = AiWriteIntentParser.TryExtractSupplierName(userMessage); |
| | | 234 | | |
| | | 235 | | // Применяем извлечённые данные: если пользователь указал цену — используем её, |
| | | 236 | | // иначе берём текущую из заказа |
| | 0 | 237 | | var effectivePrice = msgPrice ?? order.PurchasePrice ?? order.CostPrice ?? 0; |
| | | 238 | | |
| | | 239 | | // Поиск поставщика по имени если указан в сообщении |
| | | 240 | | // "Не указан" — системная запись, считаем её отсутствием поставщика |
| | 0 | 241 | | var supplierIsEmpty = order.SupplierId == null || order.Supplier?.Name == "Не указан"; |
| | 0 | 242 | | int? supplierId = supplierIsEmpty ? null : order.SupplierId; |
| | 0 | 243 | | string? supplierName = supplierIsEmpty ? null : order.Supplier?.Name; |
| | 0 | 244 | | if (!string.IsNullOrWhiteSpace(msgSupplier) && supplierIsEmpty) |
| | | 245 | | { |
| | 0 | 246 | | var found = await FindSupplierFuzzyAsync(msgSupplier, ct); |
| | 0 | 247 | | if (found != null) { supplierId = found.Id; supplierName = found.Name; } |
| | | 248 | | } |
| | | 249 | | |
| | | 250 | | // ── Обязательные поля: проверяем с учётом только что введённых данных ─ |
| | | 251 | | // Создаём временный Order-proxy с обновлёнными полями для валидации |
| | 0 | 252 | | var orderForValidation = new Order |
| | 0 | 253 | | { |
| | 0 | 254 | | AppStatusId = order.AppStatusId, |
| | 0 | 255 | | AppStatus = order.AppStatus, |
| | 0 | 256 | | ClientStatus = order.ClientStatus, |
| | 0 | 257 | | SupplierId = supplierId, |
| | 0 | 258 | | Supplier = supplierId != null ? order.Supplier : null, |
| | 0 | 259 | | ShipmentWarehouseId = order.ShipmentWarehouseId, |
| | 0 | 260 | | ShipmentWarehouse = order.ShipmentWarehouse, |
| | 0 | 261 | | PurchasePrice = (decimal)effectivePrice, |
| | 0 | 262 | | CostPrice = (decimal)effectivePrice, |
| | 0 | 263 | | }; |
| | 0 | 264 | | var missing = GetOrderLevelMissingFields(type, orderForValidation); |
| | | 265 | | |
| | | 266 | | // ── Склады-отправитель/получатель ───────────────────────────────────── |
| | 0 | 267 | | string? warehouseSender = null; |
| | 0 | 268 | | string? warehouseRecipient = null; |
| | | 269 | | |
| | 0 | 270 | | if (AiWriteIntentParser.NeedsWarehouses(type)) |
| | | 271 | | { |
| | 0 | 272 | | warehouseSender = AiWriteIntentParser.TryExtractWarehouseSender(userMessage); |
| | 0 | 273 | | warehouseRecipient = AiWriteIntentParser.TryExtractWarehouseRecipient(userMessage); |
| | 0 | 274 | | missing.AddRange(AiWriteIntentParser.GetMissingTransactionFields(type, warehouseSender, warehouseRecipient)) |
| | | 275 | | } |
| | | 276 | | |
| | 0 | 277 | | TransactionEndpoints.TryGetValue(type, out var endpoint); |
| | 0 | 278 | | OneCDocumentTypes.TryGetValue(type, out var oneCDocType); |
| | | 279 | | // ShippedBySupplier, ShippedToSeller, ShippedToCommissioner — агент проводит сам |
| | 0 | 280 | | var needs1CManual = !NoOneCTransactions.Contains(type) |
| | 0 | 281 | | && type != TransactionType.ShippedBySupplier |
| | 0 | 282 | | && type != TransactionType.ShippedToSeller |
| | 0 | 283 | | && type != TransactionType.ShippedToCommissioner; |
| | 0 | 284 | | var needs1CInput = type is TransactionType.ShippedBySupplier |
| | 0 | 285 | | or TransactionType.ShippedToSeller |
| | 0 | 286 | | or TransactionType.ShippedToCommissioner; |
| | | 287 | | |
| | | 288 | | // Для ShippedBySupplier: предзаполняем поля 1С |
| | 0 | 289 | | string? autoNumberVh = null; |
| | 0 | 290 | | string? autoComment = null; |
| | 0 | 291 | | string? supplierInn = null; |
| | 0 | 292 | | if (needs1CInput) |
| | | 293 | | { |
| | 0 | 294 | | var supplierForInn = supplierId != null |
| | 0 | 295 | | ? await _db.Set<OzonDomains.Models.Supplier>().AsNoTracking() |
| | 0 | 296 | | .FirstOrDefaultAsync(s => s.Id == supplierId, ct) |
| | 0 | 297 | | : order.Supplier; |
| | 0 | 298 | | supplierInn = supplierForInn?.INNCode?.Trim(); |
| | 0 | 299 | | autoNumberVh = $"А-{DateTime.Now:ddMMyyyy}"; |
| | 0 | 300 | | var supplierLabel = supplierName ?? supplierForInn?.Name ?? "поставщик"; |
| | 0 | 301 | | autoComment = $"Отгружен поставщиком | {supplierLabel} | {order.ShipmentNumber}"; |
| | | 302 | | } |
| | | 303 | | |
| | 0 | 304 | | return new AiActionProposalDto |
| | 0 | 305 | | { |
| | 0 | 306 | | Kind = "create_transaction", |
| | 0 | 307 | | OrderId = orderId, |
| | 0 | 308 | | ShipmentNumber = order.ShipmentNumber, |
| | 0 | 309 | | CurrentStatus = order.AppStatus?.Name, |
| | 0 | 310 | | SupplierInn = supplierInn, |
| | 0 | 311 | | TransactionDto = new AiTransactionParamsDto |
| | 0 | 312 | | { |
| | 0 | 313 | | TransactionTypeValue = (int)type, |
| | 0 | 314 | | TransactionLabel = type.GetDisplayName(), |
| | 0 | 315 | | WarehouseSenderName = warehouseSender, |
| | 0 | 316 | | WarehouseRecipientName = warehouseRecipient, |
| | 0 | 317 | | PurchasePrice = msgPrice, |
| | 0 | 318 | | SupplierId = supplierId != order.SupplierId ? supplierId : null, |
| | 0 | 319 | | SupplierName = supplierId != order.SupplierId ? supplierName : null, |
| | 0 | 320 | | ProcessIn1C = true, |
| | 0 | 321 | | NumberVh = autoNumberVh, |
| | 0 | 322 | | OneCComment = autoComment, |
| | 0 | 323 | | VatRate = 22, |
| | 0 | 324 | | }, |
| | 0 | 325 | | ManualPageUrl = endpoint != null ? $"{endpoint}?ids={orderId}&page=1" : null, |
| | 0 | 326 | | MissingFields = missing.Count > 0 ? missing : null, |
| | 0 | 327 | | Requires1CManual = needs1CManual, |
| | 0 | 328 | | Requires1CInput = needs1CInput, |
| | 0 | 329 | | OneCDocumentType = oneCDocType, |
| | 0 | 330 | | }; |
| | 0 | 331 | | } |
| | | 332 | | |
| | | 333 | | // Допустимые исходные статусы для транзакций (fallback, если StatusTransitions не настроены) |
| | 0 | 334 | | private static readonly HashSet<string> OrderedToSupplierAllowed = ["Не указан", "Заказан поставщику", "Заказан реа |
| | | 335 | | // "К отмене" не допускается для реализатора (в отличие от поставщика) |
| | 0 | 336 | | private static readonly HashSet<string> OrderedToSellerAllowed = ["Не указан", "Заказан поставщику", "Заказан реа |
| | 0 | 337 | | private static readonly HashSet<string> ToCancellationAllowed = ["Не указан", "Заказан поставщику", "Заказан реа |
| | 0 | 338 | | private static readonly HashSet<string> CancelledAllowed = ["Заказан поставщику", "К отмене", "Не указан"]; |
| | 0 | 339 | | private static readonly HashSet<string> WarehouseChangeAllowed = ["Не указан", "Заказан поставщику", "Заказан реа |
| | 0 | 340 | | "Отгружен поставщиком", "Принят на складе", "К |
| | 0 | 341 | | private static readonly HashSet<string> ReturnTransferAllowed = ["Возврат покупателя"]; |
| | | 342 | | |
| | | 343 | | /// <summary> |
| | | 344 | | /// Проверяет обязательные поля и допустимость состояния заказа для данного типа транзакции. |
| | | 345 | | /// Возвращает список блокирующих проблем (если пусто — транзакция разрешена). |
| | | 346 | | /// </summary> |
| | | 347 | | private static List<string> GetOrderLevelMissingFields(TransactionType type, Order order) |
| | | 348 | | { |
| | 0 | 349 | | var missing = new List<string>(); |
| | 0 | 350 | | var appStatus = order.AppStatus?.Name ?? "Не указан"; |
| | 0 | 351 | | var clientStatus = order.ClientStatus?.Name ?? ""; |
| | | 352 | | |
| | | 353 | | switch (type) |
| | | 354 | | { |
| | | 355 | | // ── Заказан поставщику ────────────────────────────────────────────── |
| | | 356 | | case TransactionType.OrderedToSupplier: |
| | | 357 | | // Блокировки по клиентскому статусу Ozon |
| | 0 | 358 | | if (clientStatus == "Отменен покупателем") |
| | 0 | 359 | | missing.Add("заказ отменён покупателем на Ozon — транзакция недоступна"); |
| | 0 | 360 | | else if (appStatus == "Не указан" && clientStatus == "Отменён") |
| | 0 | 361 | | missing.Add("заказ отменён на Ozon (статус Ozon: «Отменён») — транзакция недоступна"); |
| | 0 | 362 | | else if (appStatus == "Отменен" && clientStatus == "Отменён") |
| | 0 | 363 | | missing.Add("заказ уже отменён — транзакция недоступна"); |
| | | 364 | | // Проверка допустимого исходного статуса |
| | 0 | 365 | | else if (!OrderedToSupplierAllowed.Contains(appStatus)) |
| | 0 | 366 | | missing.Add($"статус «{appStatus}» не допускает эту транзакцию (ожидается: Не указан / Заказан поста |
| | | 367 | | // Обязательные поля |
| | 0 | 368 | | if (order.SupplierId == null || order.Supplier?.Name == "Не указан") |
| | 0 | 369 | | missing.Add("поставщик не назначен — укажите поставщика в заказе"); |
| | 0 | 370 | | if ((order.PurchasePrice ?? order.CostPrice ?? 0) <= 0) |
| | 0 | 371 | | missing.Add("цена закупки не указана или равна 0 — укажите цену закупки"); |
| | 0 | 372 | | break; |
| | | 373 | | |
| | | 374 | | // ── Заказан реализатору ───────────────────────────────────────────── |
| | | 375 | | case TransactionType.OrderedToSeller: |
| | | 376 | | // Блокировки по клиентскому статусу (аналог OrderedToSupplier) |
| | 0 | 377 | | if (clientStatus == "Отменен покупателем") |
| | 0 | 378 | | missing.Add("заказ отменён покупателем на Ozon — транзакция недоступна"); |
| | 0 | 379 | | else if (appStatus == "Не указан" && clientStatus == "Отменён") |
| | 0 | 380 | | missing.Add("заказ отменён на Ozon (статус Ozon: «Отменён») — транзакция недоступна"); |
| | 0 | 381 | | else if (appStatus == "Отменен" && clientStatus == "Отменён") |
| | 0 | 382 | | missing.Add("заказ уже отменён — транзакция недоступна"); |
| | 0 | 383 | | else if (!OrderedToSellerAllowed.Contains(appStatus)) |
| | 0 | 384 | | missing.Add($"статус «{appStatus}» не допускает эту транзакцию (ожидается: Не указан / Заказан поста |
| | | 385 | | // Обязательные поля (мягкая валидация на submit, но сервер требует поставщика) |
| | 0 | 386 | | if (order.SupplierId == null || order.Supplier?.Name == "Не указан") |
| | 0 | 387 | | missing.Add("реализатор (поставщик) не назначен — укажите поставщика"); |
| | 0 | 388 | | if ((order.PurchasePrice ?? order.CostPrice ?? 0) <= 0) |
| | 0 | 389 | | missing.Add("цена закупки не указана или равна 0 — укажите цену закупки"); |
| | 0 | 390 | | break; |
| | | 391 | | |
| | | 392 | | // ── Отгружен поставщиком (1С: приходная) ─────────────────────────── |
| | | 393 | | case TransactionType.ShippedBySupplier: |
| | 0 | 394 | | if (appStatus != "Заказан поставщику") |
| | 0 | 395 | | missing.Add($"статус «{appStatus}» не допускает эту транзакцию (ожидается: Заказан поставщику)"); |
| | 0 | 396 | | if (order.SupplierId == null) |
| | 0 | 397 | | missing.Add("поставщик не назначен"); |
| | 0 | 398 | | if ((order.PurchasePrice ?? order.CostPrice ?? 0) <= 0) |
| | 0 | 399 | | missing.Add("цена закупки не указана или равна 0"); |
| | 0 | 400 | | break; |
| | | 401 | | |
| | | 402 | | // ── Отгружен реализатором (1С: перемещение) ──────────────────────── |
| | | 403 | | case TransactionType.ShippedToSeller: |
| | 0 | 404 | | if (appStatus != "Заказан реализатору") |
| | 0 | 405 | | missing.Add($"статус «{appStatus}» не допускает эту транзакцию (ожидается: Заказан реализатору)"); |
| | 0 | 406 | | break; |
| | | 407 | | |
| | | 408 | | // ── Принят на складе (учётная операция, без 1С) ──────────────────── |
| | | 409 | | case TransactionType.AcceptedToWarehouse: |
| | 0 | 410 | | if (appStatus is not ("Заказан поставщику" or "Не указан" or "Отгружен поставщиком")) |
| | 0 | 411 | | missing.Add($"статус «{appStatus}» не допускает эту транзакцию (ожидается: Заказан поставщику / Не у |
| | 0 | 412 | | if (order.SupplierId == null || order.Supplier?.Name == "Не указан") |
| | 0 | 413 | | missing.Add("поставщик не назначен — укажите поставщика в заказе"); |
| | 0 | 414 | | if ((order.PurchasePrice ?? order.CostPrice ?? 0) <= 0) |
| | 0 | 415 | | missing.Add("цена закупки не указана или равна 0 — укажите цену закупки"); |
| | 0 | 416 | | break; |
| | | 417 | | |
| | | 418 | | // ── Отгружен комиссионеру (1С: перемещение с перевёрнутыми складами) ─ |
| | | 419 | | case TransactionType.ShippedToCommissioner: |
| | 0 | 420 | | if (clientStatus == "Отменен покупателем") |
| | 0 | 421 | | missing.Add("заказ отменён покупателем на Ozon — транзакция недоступна"); |
| | 0 | 422 | | if (order.SupplierId == null || order.Supplier?.Name == "Не указан") |
| | 0 | 423 | | missing.Add("поставщик не назначен — укажите поставщика в заказе"); |
| | 0 | 424 | | if ((order.PurchasePrice ?? order.CostPrice ?? 0) <= 0) |
| | 0 | 425 | | missing.Add("цена закупки не указана или равна 0 — укажите цену закупки"); |
| | 0 | 426 | | break; |
| | | 427 | | |
| | | 428 | | // ── Отгружен клиенту (1С: перемещение + расходная) ───────────────── |
| | | 429 | | case TransactionType.ShippedToClient: |
| | 0 | 430 | | if (order.ShipmentWarehouseId == null) |
| | 0 | 431 | | missing.Add("склад отгрузки не назначен"); |
| | 0 | 432 | | break; |
| | | 433 | | |
| | | 434 | | // ── К отмене ──────────────────────────────────────────────────────── |
| | | 435 | | case TransactionType.ToCancellation: |
| | 0 | 436 | | if (!ToCancellationAllowed.Contains(appStatus)) |
| | 0 | 437 | | missing.Add($"статус «{appStatus}» не допускает эту транзакцию (ожидается: Не указан / Заказан поста |
| | 0 | 438 | | break; |
| | | 439 | | |
| | | 440 | | // ── Отменен ───────────────────────────────────────────────────────── |
| | | 441 | | case TransactionType.Cancelled: |
| | 0 | 442 | | if (!CancelledAllowed.Contains(appStatus)) |
| | 0 | 443 | | missing.Add($"статус «{appStatus}» не допускает отмену (ожидается: Не указан / Заказан поставщику / |
| | 0 | 444 | | if (appStatus == "Отменен") |
| | 0 | 445 | | missing.Add("заказ уже отменён"); |
| | 0 | 446 | | break; |
| | | 447 | | |
| | | 448 | | // ── Изменение склада ───────────────────────────────────────────────── |
| | | 449 | | case TransactionType.WarehouseChange: |
| | 0 | 450 | | if (!WarehouseChangeAllowed.Contains(appStatus)) |
| | 0 | 451 | | missing.Add($"статус «{appStatus}» не допускает эту транзакцию"); |
| | 0 | 452 | | break; |
| | | 453 | | |
| | | 454 | | // ── Возврат покупателя (1С: перемещение) ──────────────────────────── |
| | | 455 | | case TransactionType.CustomerReturn: |
| | 0 | 456 | | if (appStatus is not ("Отгружен клиенту" or "Отменен")) |
| | 0 | 457 | | missing.Add($"статус «{appStatus}» не допускает возврат покупателя (ожидается: Отгружен клиенту / От |
| | 0 | 458 | | break; |
| | | 459 | | |
| | | 460 | | // ── Перемещение (Return) ───────────────────────────────────────────── |
| | | 461 | | case TransactionType.ReturnTransfer: |
| | 0 | 462 | | if (!ReturnTransferAllowed.Contains(appStatus)) |
| | 0 | 463 | | missing.Add($"статус «{appStatus}» не допускает эту транзакцию (ожидается: Возврат покупателя)"); |
| | | 464 | | break; |
| | | 465 | | } |
| | 0 | 466 | | 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 | | { |
| | 0 | 480 | | var order = await _db.Orders |
| | 0 | 481 | | .Include(o => o.AppStatus) |
| | 0 | 482 | | .Include(o => o.Supplier) |
| | 0 | 483 | | .Include(o => o.ShipmentWarehouse) |
| | 0 | 484 | | .FirstOrDefaultAsync(o => o.Id == orderId, ct); |
| | | 485 | | |
| | 0 | 486 | | if (order == null) |
| | 0 | 487 | | return Fail($"Заказ #{orderId} не найден."); |
| | | 488 | | |
| | 0 | 489 | | var changedFields = new List<string>(); |
| | 0 | 490 | | var oldStatusId = order.AppStatusId; |
| | | 491 | | // "Иван Иванов [AI Стас: deepseek/deepseek-v4-flash]" |
| | 0 | 492 | | var historyAuthor = FormatHistoryAuthor(userName, model); |
| | | 493 | | |
| | 0 | 494 | | if (dto.AppStatusId.HasValue && dto.AppStatusId != order.AppStatusId) |
| | | 495 | | { |
| | 0 | 496 | | var oldName = order.AppStatus?.Name ?? "—"; |
| | 0 | 497 | | order.AppStatusId = dto.AppStatusId.Value; |
| | 0 | 498 | | order.LastStatusChangeDate = DateTime.Now; |
| | 0 | 499 | | changedFields.Add($"Статус: «{oldName}» → «{dto.AppStatusName}»"); |
| | | 500 | | |
| | | 501 | | // Запись в orders_history |
| | 0 | 502 | | _db.OrderHistories.Add(new OzonDomains.Models.OrderHistory |
| | 0 | 503 | | { |
| | 0 | 504 | | OrderId = orderId, |
| | 0 | 505 | | ColumnName = "AppStatusId", |
| | 0 | 506 | | ColumnDisplayName = "Статус", |
| | 0 | 507 | | OldValue = oldName, |
| | 0 | 508 | | NewValue = dto.AppStatusName ?? dto.AppStatusId.Value.ToString(), |
| | 0 | 509 | | ChangedAt = DateTime.UtcNow, |
| | 0 | 510 | | ChangedBy = historyAuthor, |
| | 0 | 511 | | }); |
| | | 512 | | } |
| | | 513 | | |
| | 0 | 514 | | if (dto.SupplierId.HasValue && dto.SupplierId != order.SupplierId) |
| | | 515 | | { |
| | 0 | 516 | | var oldSupplierName = order.Supplier?.Name ?? "—"; |
| | 0 | 517 | | order.SupplierId = dto.SupplierId.Value; |
| | 0 | 518 | | changedFields.Add($"Поставщик: «{oldSupplierName}» → «{dto.SupplierName}»"); |
| | | 519 | | |
| | 0 | 520 | | _db.OrderHistories.Add(new OzonDomains.Models.OrderHistory |
| | 0 | 521 | | { |
| | 0 | 522 | | OrderId = orderId, |
| | 0 | 523 | | ColumnName = "SupplierId", |
| | 0 | 524 | | ColumnDisplayName = "Поставщик", |
| | 0 | 525 | | OldValue = oldSupplierName, |
| | 0 | 526 | | NewValue = dto.SupplierName ?? dto.SupplierId.Value.ToString(), |
| | 0 | 527 | | ChangedAt = DateTime.UtcNow, |
| | 0 | 528 | | ChangedBy = historyAuthor, |
| | 0 | 529 | | }); |
| | | 530 | | } |
| | | 531 | | |
| | 0 | 532 | | if (dto.ShipmentWarehouseId.HasValue && dto.ShipmentWarehouseId != order.ShipmentWarehouseId) |
| | | 533 | | { |
| | 0 | 534 | | var oldWarehouseName = order.ShipmentWarehouse?.Name ?? "—"; |
| | 0 | 535 | | order.ShipmentWarehouseId = dto.ShipmentWarehouseId.Value; |
| | 0 | 536 | | changedFields.Add($"Склад: «{oldWarehouseName}» → «{dto.WarehouseName}»"); |
| | | 537 | | |
| | 0 | 538 | | _db.OrderHistories.Add(new OzonDomains.Models.OrderHistory |
| | 0 | 539 | | { |
| | 0 | 540 | | OrderId = orderId, |
| | 0 | 541 | | ColumnName = "ShipmentWarehouseId", |
| | 0 | 542 | | ColumnDisplayName = "Склад отгрузки", |
| | 0 | 543 | | OldValue = oldWarehouseName, |
| | 0 | 544 | | NewValue = dto.WarehouseName ?? dto.ShipmentWarehouseId.Value.ToString(), |
| | 0 | 545 | | ChangedAt = DateTime.UtcNow, |
| | 0 | 546 | | ChangedBy = historyAuthor, |
| | 0 | 547 | | }); |
| | | 548 | | } |
| | | 549 | | |
| | 0 | 550 | | if (dto.PurchasePrice.HasValue && dto.PurchasePrice != order.CostPrice) |
| | | 551 | | { |
| | 0 | 552 | | var oldPrice = order.CostPrice; |
| | 0 | 553 | | order.CostPrice = dto.PurchasePrice.Value; |
| | 0 | 554 | | changedFields.Add($"Цена закупки: {oldPrice:N0} → {dto.PurchasePrice:N0} ₽"); |
| | | 555 | | } |
| | | 556 | | |
| | 0 | 557 | | if (dto.Quantity.HasValue && dto.Quantity != order.Quantity) |
| | | 558 | | { |
| | 0 | 559 | | var oldQty = order.Quantity; |
| | 0 | 560 | | order.Quantity = dto.Quantity.Value; |
| | 0 | 561 | | changedFields.Add($"Количество: {oldQty} → {dto.Quantity}"); |
| | | 562 | | } |
| | | 563 | | |
| | 0 | 564 | | if (!string.IsNullOrWhiteSpace(dto.OrderNumberToSupplier)) |
| | | 565 | | { |
| | 0 | 566 | | order.OrderNumberToSupplier = dto.OrderNumberToSupplier; |
| | 0 | 567 | | changedFields.Add($"Номер заказа поставщику: {dto.OrderNumberToSupplier}"); |
| | | 568 | | } |
| | | 569 | | |
| | 0 | 570 | | if (changedFields.Count == 0) |
| | 0 | 571 | | return Fail("Нет изменений для сохранения."); |
| | | 572 | | |
| | 0 | 573 | | order.UpdatedBy = userName; |
| | 0 | 574 | | await _db.SaveChangesAsync(ct); |
| | | 575 | | |
| | | 576 | | // Пересчёт прибыли/наценки если изменена цена или поставщик |
| | 0 | 577 | | if (dto.PurchasePrice.HasValue || dto.SupplierId.HasValue) |
| | 0 | 578 | | await RecalcProfitAsync(orderId, ct); |
| | | 579 | | |
| | 0 | 580 | | _logger.LogInformation("AI edit order #{Id}: {Changes}", orderId, string.Join(", ", changedFields)); |
| | | 581 | | |
| | 0 | 582 | | return new AiExecuteActionResponse |
| | 0 | 583 | | { |
| | 0 | 584 | | Success = true, |
| | 0 | 585 | | Message = $"Изменения сохранены:\n• {string.Join("\n• ", changedFields)}", |
| | 0 | 586 | | NewStatus = dto.AppStatusName ?? order.AppStatus?.Name, |
| | 0 | 587 | | ShipmentNumber = order.ShipmentNumber, |
| | 0 | 588 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 589 | | ManualPageUrl = $"/Studio2/Orders/MultiplayEditV2?ids={orderId}&page=1", |
| | 0 | 590 | | }; |
| | | 591 | | } |
| | 0 | 592 | | catch (Exception ex) |
| | | 593 | | { |
| | 0 | 594 | | _logger.LogError(ex, "AI edit order #{Id} failed", orderId); |
| | 0 | 595 | | return Fail($"Ошибка при сохранении: {ex.Message}"); |
| | | 596 | | } |
| | 0 | 597 | | } |
| | | 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 | | { |
| | 0 | 608 | | if (!Enum.TryParse<TransactionType>(dto.TransactionTypeValue.ToString(), out var type)) |
| | 0 | 609 | | return Fail("Неизвестный тип транзакции."); |
| | | 610 | | |
| | 0 | 611 | | TransactionEndpoints.TryGetValue(type, out var endpoint); |
| | 0 | 612 | | var manualUrl = endpoint != null ? $"{endpoint}?ids={orderId}&page=1" : null; |
| | | 613 | | |
| | | 614 | | // ShippedBySupplier выполняется агентом напрямую с опциональной 1С-проводкой |
| | 0 | 615 | | if (type == TransactionType.ShippedBySupplier) |
| | 0 | 616 | | return await ExecuteShippedBySupplierAsync(orderId, dto, userName, ct, model); |
| | | 617 | | |
| | | 618 | | // ShippedToSeller выполняется агентом напрямую с опциональной 1С-проводкой и rollback |
| | 0 | 619 | | if (type == TransactionType.ShippedToSeller) |
| | 0 | 620 | | return await ExecuteShippedToSellerAsync(orderId, dto, userName, ct, model); |
| | | 621 | | |
| | | 622 | | // ShippedToCommissioner — отгрузка на Ozon FBO с rollback |
| | 0 | 623 | | if (type == TransactionType.ShippedToCommissioner) |
| | 0 | 624 | | return await ExecuteShippedToCommissionerAsync(orderId, dto, userName, ct, model); |
| | | 625 | | |
| | | 626 | | // Остальные транзакции с 1С — не выполняем автоматически, отправляем на страницу |
| | 0 | 627 | | if (!NoOneCTransactions.Contains(type)) |
| | | 628 | | { |
| | 0 | 629 | | return new AiExecuteActionResponse |
| | 0 | 630 | | { |
| | 0 | 631 | | Success = false, |
| | 0 | 632 | | Message = $"Транзакция «{dto.TransactionLabel}» требует проводки в 1С. " + |
| | 0 | 633 | | "Пожалуйста, выполните её вручную по кнопке «Открыть страницу».", |
| | 0 | 634 | | ManualPageUrl = manualUrl, |
| | 0 | 635 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 636 | | }; |
| | | 637 | | } |
| | | 638 | | |
| | | 639 | | try |
| | | 640 | | { |
| | 0 | 641 | | var order = await _db.Orders |
| | 0 | 642 | | .Include(o => o.AppStatus) |
| | 0 | 643 | | .Include(o => o.Supplier) |
| | 0 | 644 | | .Include(o => o.ShipmentWarehouse) |
| | 0 | 645 | | .Include(o => o.ClientStatus) |
| | 0 | 646 | | .FirstOrDefaultAsync(o => o.Id == orderId, ct); |
| | | 647 | | |
| | 0 | 648 | | if (order == null) |
| | 0 | 649 | | return Fail($"Заказ #{orderId} не найден."); |
| | | 650 | | |
| | 0 | 651 | | var oldStatus = order.AppStatus?.Name ?? "—"; |
| | 0 | 652 | | var oldStatusId = order.AppStatusId; |
| | 0 | 653 | | var historyAuthor = FormatHistoryAuthor(userName, model); |
| | | 654 | | |
| | | 655 | | // ── Применяем поля, переданные в TransactionDto ПЕРЕД валидацией ── |
| | | 656 | | // Цена закупки (если была указана пользователем при уточнении) |
| | 0 | 657 | | if (dto.PurchasePrice is > 0) |
| | | 658 | | { |
| | 0 | 659 | | var oldPrice = order.PurchasePrice ?? order.CostPrice ?? 0; |
| | 0 | 660 | | order.PurchasePrice = dto.PurchasePrice; |
| | 0 | 661 | | order.CostPrice = dto.PurchasePrice; |
| | 0 | 662 | | _db.OrderHistories.Add(new OzonDomains.Models.OrderHistory |
| | 0 | 663 | | { |
| | 0 | 664 | | OrderId = orderId, |
| | 0 | 665 | | ColumnName = "PurchasePrice", |
| | 0 | 666 | | ColumnDisplayName = "Цена закупки", |
| | 0 | 667 | | OldValue = oldPrice.ToString(), |
| | 0 | 668 | | NewValue = dto.PurchasePrice.Value.ToString(), |
| | 0 | 669 | | ChangedAt = DateTime.UtcNow, |
| | 0 | 670 | | ChangedBy = historyAuthor, |
| | 0 | 671 | | }); |
| | | 672 | | } |
| | | 673 | | |
| | | 674 | | // Поставщик (если был указан пользователем при уточнении) |
| | 0 | 675 | | if (dto.SupplierId is > 0 && dto.SupplierId != order.SupplierId) |
| | | 676 | | { |
| | 0 | 677 | | var oldSupplier = order.Supplier?.Name ?? "—"; |
| | 0 | 678 | | order.SupplierId = dto.SupplierId; |
| | 0 | 679 | | order.Supplier = null; // сбрасываем навигацию, чтобы валидация не видела старое "Не указан" |
| | 0 | 680 | | _db.OrderHistories.Add(new OzonDomains.Models.OrderHistory |
| | 0 | 681 | | { |
| | 0 | 682 | | OrderId = orderId, |
| | 0 | 683 | | ColumnName = "SupplierId", |
| | 0 | 684 | | ColumnDisplayName = "Поставщик", |
| | 0 | 685 | | OldValue = oldSupplier, |
| | 0 | 686 | | NewValue = dto.SupplierName ?? dto.SupplierId.Value.ToString(), |
| | 0 | 687 | | ChangedAt = DateTime.UtcNow, |
| | 0 | 688 | | ChangedBy = historyAuthor, |
| | 0 | 689 | | }); |
| | | 690 | | } |
| | | 691 | | |
| | | 692 | | // ── Повторная валидация ПОСЛЕ применения dto-полей ──────────────── |
| | 0 | 693 | | var preValidation = GetOrderLevelMissingFields(type, order); |
| | 0 | 694 | | if (preValidation.Count > 0) |
| | 0 | 695 | | return new AiExecuteActionResponse |
| | 0 | 696 | | { |
| | 0 | 697 | | Success = false, |
| | 0 | 698 | | Message = $"Транзакция заблокирована:\n• {string.Join("\n• ", preValidation)}", |
| | 0 | 699 | | ManualPageUrl = manualUrl, |
| | 0 | 700 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 701 | | }; |
| | | 702 | | |
| | | 703 | | // Определяем целевой статус по типу транзакции |
| | 0 | 704 | | var targetStatusName = GetTargetStatus(type); |
| | 0 | 705 | | AppStatus? newStatus = null; |
| | 0 | 706 | | if (!string.IsNullOrWhiteSpace(targetStatusName)) |
| | | 707 | | { |
| | 0 | 708 | | newStatus = await _db.AppStatuses.AsNoTracking() |
| | 0 | 709 | | .FirstOrDefaultAsync(s => s.Name == targetStatusName, ct); |
| | 0 | 710 | | if (newStatus != null) |
| | | 711 | | { |
| | 0 | 712 | | order.AppStatusId = newStatus.Id; |
| | 0 | 713 | | order.LastStatusChangeDate = DateTime.Now; |
| | | 714 | | |
| | 0 | 715 | | _db.OrderHistories.Add(new OzonDomains.Models.OrderHistory |
| | 0 | 716 | | { |
| | 0 | 717 | | OrderId = orderId, |
| | 0 | 718 | | ColumnName = "AppStatusId", |
| | 0 | 719 | | ColumnDisplayName = "Статус", |
| | 0 | 720 | | OldValue = oldStatus, |
| | 0 | 721 | | NewValue = newStatus.Name, |
| | 0 | 722 | | ChangedAt = DateTime.UtcNow, |
| | 0 | 723 | | ChangedBy = historyAuthor, |
| | 0 | 724 | | }); |
| | | 725 | | } |
| | | 726 | | } |
| | | 727 | | |
| | 0 | 728 | | order.IsAccepted = true; |
| | 0 | 729 | | order.UpdatedBy = historyAuthor; |
| | 0 | 730 | | await _db.SaveChangesAsync(ct); |
| | | 731 | | |
| | | 732 | | // Пересчёт прибыли/наценки (та же цепочка что в настоящей транзакции) |
| | 0 | 733 | | await RecalcProfitAsync(orderId, ct); |
| | | 734 | | |
| | | 735 | | // Создаём запись транзакции |
| | 0 | 736 | | var (_, _, transactionId) = await CreateTransactionByTypeAsync( |
| | 0 | 737 | | type, [order], userName, DateTime.Now, dto.Comment ?? "AI-ассистент"); |
| | | 738 | | |
| | 0 | 739 | | _logger.LogInformation("AI transaction #{TxId} type={Type} order={OrderId}", transactionId, type, orderId); |
| | | 740 | | |
| | 0 | 741 | | var statusChange = newStatus != null ? $" → «{newStatus.Name}»" : ""; |
| | 0 | 742 | | return new AiExecuteActionResponse |
| | 0 | 743 | | { |
| | 0 | 744 | | Success = true, |
| | 0 | 745 | | Message = $"Транзакция «{dto.TransactionLabel}» создана (ID: {transactionId})." + |
| | 0 | 746 | | (statusChange != "" ? $"\nСтатус изменён: «{oldStatus}»{statusChange}" : ""), |
| | 0 | 747 | | NewStatus = newStatus?.Name, |
| | 0 | 748 | | ShipmentNumber = order.ShipmentNumber, |
| | 0 | 749 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 750 | | TransactionPageUrl = transactionId > 0 ? $"/Studio2/Transactions/Details/{transactionId}" : null, |
| | 0 | 751 | | ManualPageUrl = manualUrl, |
| | 0 | 752 | | }; |
| | | 753 | | } |
| | 0 | 754 | | catch (Exception ex) |
| | | 755 | | { |
| | 0 | 756 | | _logger.LogError(ex, "AI transaction type={Type} order=#{Id} failed", type, orderId); |
| | 0 | 757 | | return Fail($"Ошибка при создании транзакции: {ex.Message}"); |
| | | 758 | | } |
| | 0 | 759 | | } |
| | | 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 | | { |
| | 0 | 770 | | TransactionEndpoints.TryGetValue(TransactionType.ShippedBySupplier, out var endpoint); |
| | 0 | 771 | | var manualUrl = endpoint != null ? $"{endpoint}?ids={orderId}&page=1" : null; |
| | | 772 | | |
| | | 773 | | try |
| | | 774 | | { |
| | 0 | 775 | | var order = await _db.Orders |
| | 0 | 776 | | .Include(o => o.AppStatus) |
| | 0 | 777 | | .Include(o => o.Supplier) |
| | 0 | 778 | | .Include(o => o.ShipmentWarehouse) |
| | 0 | 779 | | .Include(o => o.ClientStatus) |
| | 0 | 780 | | .Include(o => o.OzonClient) |
| | 0 | 781 | | .Include(o => o.OrderExtensions) |
| | 0 | 782 | | .FirstOrDefaultAsync(o => o.Id == orderId, ct); |
| | | 783 | | |
| | 0 | 784 | | if (order == null) |
| | 0 | 785 | | return Fail($"Заказ #{orderId} не найден."); |
| | | 786 | | |
| | 0 | 787 | | var oldStatus = order.AppStatus?.Name ?? "—"; |
| | 0 | 788 | | var historyAuthor = FormatHistoryAuthor(userName, model); |
| | | 789 | | |
| | | 790 | | // Применяем цену если передана |
| | 0 | 791 | | if (dto.PurchasePrice is > 0) |
| | | 792 | | { |
| | 0 | 793 | | order.PurchasePrice = dto.PurchasePrice; |
| | 0 | 794 | | order.CostPrice = dto.PurchasePrice; |
| | 0 | 795 | | _db.OrderHistories.Add(new OrderHistory |
| | 0 | 796 | | { |
| | 0 | 797 | | OrderId = orderId, |
| | 0 | 798 | | ColumnName = "PurchasePrice", |
| | 0 | 799 | | ColumnDisplayName = "Цена закупки", |
| | 0 | 800 | | OldValue = (order.PurchasePrice ?? order.CostPrice ?? 0).ToString(), |
| | 0 | 801 | | NewValue = dto.PurchasePrice.Value.ToString(), |
| | 0 | 802 | | ChangedAt = DateTime.UtcNow, |
| | 0 | 803 | | ChangedBy = historyAuthor, |
| | 0 | 804 | | }); |
| | | 805 | | } |
| | | 806 | | |
| | | 807 | | // Повторная валидация |
| | 0 | 808 | | var preValidation = GetOrderLevelMissingFields(TransactionType.ShippedBySupplier, order); |
| | 0 | 809 | | if (preValidation.Count > 0) |
| | 0 | 810 | | return new AiExecuteActionResponse |
| | 0 | 811 | | { |
| | 0 | 812 | | Success = false, |
| | 0 | 813 | | Message = $"Транзакция заблокирована:\n• {string.Join("\n• ", preValidation)}", |
| | 0 | 814 | | ManualPageUrl = manualUrl, |
| | 0 | 815 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 816 | | }; |
| | | 817 | | |
| | | 818 | | // Определяем эффективное количество |
| | 0 | 819 | | var qty = order.Quantity ?? 0; |
| | | 820 | | |
| | | 821 | | // Смена статуса: Отгружен поставщиком (qty > 0) или Утерян поставщиком (qty <= 0) |
| | 0 | 822 | | var targetStatusName = qty > 0 ? "Отгружен поставщиком" : "Утерян поставщиком"; |
| | 0 | 823 | | var newAppStatus = await _db.AppStatuses.AsNoTracking() |
| | 0 | 824 | | .FirstOrDefaultAsync(s => s.Name == targetStatusName, ct); |
| | | 825 | | |
| | 0 | 826 | | if (newAppStatus != null) |
| | | 827 | | { |
| | 0 | 828 | | order.AppStatusId = newAppStatus.Id; |
| | 0 | 829 | | order.LastStatusChangeDate = DateTime.Now; |
| | 0 | 830 | | _db.OrderHistories.Add(new OrderHistory |
| | 0 | 831 | | { |
| | 0 | 832 | | OrderId = orderId, |
| | 0 | 833 | | ColumnName = "AppStatusId", |
| | 0 | 834 | | ColumnDisplayName = "Статус", |
| | 0 | 835 | | OldValue = oldStatus, |
| | 0 | 836 | | NewValue = newAppStatus.Name, |
| | 0 | 837 | | ChangedAt = DateTime.UtcNow, |
| | 0 | 838 | | ChangedBy = historyAuthor, |
| | 0 | 839 | | }); |
| | | 840 | | } |
| | | 841 | | |
| | 0 | 842 | | order.IsAccepted = true; |
| | 0 | 843 | | order.UpdatedBy = historyAuthor; |
| | 0 | 844 | | await _db.SaveChangesAsync(ct); |
| | 0 | 845 | | await RecalcProfitAsync(orderId, ct); |
| | | 846 | | |
| | | 847 | | // Создаём запись транзакции (только если qty > 0) |
| | 0 | 848 | | int transactionId = 0; |
| | 0 | 849 | | if (qty > 0) |
| | | 850 | | { |
| | 0 | 851 | | var (_, _, txId) = await _transactionManager.CreateShippedBySupplierTransaction( |
| | 0 | 852 | | [order], userName, DateTime.Now, dto.Comment ?? "AI-ассистент"); |
| | 0 | 853 | | transactionId = txId; |
| | | 854 | | } |
| | | 855 | | |
| | 0 | 856 | | _logger.LogInformation("AI ShippedBySupplier tx=#{TxId} order=#{OrderId} processIn1C={Flag}", |
| | 0 | 857 | | transactionId, orderId, dto.ProcessIn1C); |
| | | 858 | | |
| | | 859 | | // ── 1С-проводка ──────────────────────────────────────────────────── |
| | 0 | 860 | | string? oneCResult = null; |
| | 0 | 861 | | if (dto.ProcessIn1C && qty > 0) |
| | | 862 | | { |
| | | 863 | | try |
| | | 864 | | { |
| | | 865 | | // Перезагружаем заказ с полными навигациями для 1С |
| | 0 | 866 | | var orderFor1C = await _db.Orders |
| | 0 | 867 | | .Include(o => o.Supplier) |
| | 0 | 868 | | .Include(o => o.OzonClient) |
| | 0 | 869 | | .Include(o => o.OrderExtensions) |
| | 0 | 870 | | .Include(o => o.Manufacturer) |
| | 0 | 871 | | .Include(o => o.ProductInfo) |
| | 0 | 872 | | .FirstOrDefaultAsync(o => o.Id == orderId, ct); |
| | | 873 | | |
| | 0 | 874 | | if (orderFor1C != null) |
| | | 875 | | { |
| | 0 | 876 | | var receiptDto = new ShippedBySupplierTransactionDto |
| | 0 | 877 | | { |
| | 0 | 878 | | ContractLink = dto.ContractLink, |
| | 0 | 879 | | NumberVh = dto.NumberVh ?? string.Empty, |
| | 0 | 880 | | DateVh = dto.DateVh ?? DateTime.Today, |
| | 0 | 881 | | VatRate = dto.VatRate, |
| | 0 | 882 | | TransactionComment = !string.IsNullOrWhiteSpace(dto.OneCComment) |
| | 0 | 883 | | ? dto.OneCComment |
| | 0 | 884 | | : $"Отгружен поставщиком (AI-ассистент: {userName})" + |
| | 0 | 885 | | (string.IsNullOrWhiteSpace(dto.Comment) ? "" : $". {dto.Comment}"), |
| | 0 | 886 | | }; |
| | | 887 | | |
| | 0 | 888 | | var responses = await _oneCReceiptManager.CreateReceipts( |
| | 0 | 889 | | [orderFor1C], receiptDto, userName); |
| | | 890 | | |
| | 0 | 891 | | var successParts = responses |
| | 0 | 892 | | .Where(r => r.Success) |
| | 0 | 893 | | .Select(r => |
| | 0 | 894 | | { |
| | 0 | 895 | | var num = r.DocumentNumber != null ? $" №{r.DocumentNumber}" : ""; |
| | 0 | 896 | | var miss = !string.IsNullOrWhiteSpace(r.MissingNomenclature) |
| | 0 | 897 | | ? $" (черновик, не найдено: {r.MissingNomenclature})" |
| | 0 | 898 | | : (r.IsPosted ? " (проведена)" : " (черновик)"); |
| | 0 | 899 | | return $"✅ Накладная создана{num}{miss}"; |
| | 0 | 900 | | }) |
| | 0 | 901 | | .ToList(); |
| | 0 | 902 | | var failParts = responses |
| | 0 | 903 | | .Where(r => !r.Success) |
| | 0 | 904 | | .Select(r => $"⚠️ {r.Message}") |
| | 0 | 905 | | .ToList(); |
| | | 906 | | |
| | 0 | 907 | | oneCResult = string.Join("\n", successParts.Concat(failParts)); |
| | 0 | 908 | | if (string.IsNullOrWhiteSpace(oneCResult)) |
| | 0 | 909 | | oneCResult = "Результат из 1С не получен."; |
| | | 910 | | } |
| | 0 | 911 | | } |
| | 0 | 912 | | catch (Exception ex1C) |
| | | 913 | | { |
| | 0 | 914 | | _logger.LogWarning(ex1C, "AI ShippedBySupplier: 1C receipt failed for order #{Id} (non-blocking)", o |
| | 0 | 915 | | oneCResult = $"⚠️ Ошибка при создании приходной накладной в 1С: {ex1C.Message}"; |
| | 0 | 916 | | } |
| | | 917 | | } |
| | | 918 | | |
| | 0 | 919 | | var statusInfo = newAppStatus != null ? $"«{oldStatus}» → «{newAppStatus.Name}»" : ""; |
| | 0 | 920 | | var txInfo = transactionId > 0 ? $" Транзакция #{transactionId}." : ""; |
| | 0 | 921 | | var oneCInfo = oneCResult != null ? $"\n\n**1С:** {oneCResult}" : ""; |
| | | 922 | | |
| | 0 | 923 | | return new AiExecuteActionResponse |
| | 0 | 924 | | { |
| | 0 | 925 | | Success = true, |
| | 0 | 926 | | Message = $"Транзакция «Отгружен поставщиком» создана.{txInfo}" + |
| | 0 | 927 | | (statusInfo != "" ? $"\nСтатус: {statusInfo}" : "") + oneCInfo, |
| | 0 | 928 | | NewStatus = newAppStatus?.Name, |
| | 0 | 929 | | ShipmentNumber = order.ShipmentNumber, |
| | 0 | 930 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 931 | | TransactionPageUrl = transactionId > 0 ? $"/Studio2/Transactions/Details/{transactionId}" : null, |
| | 0 | 932 | | ManualPageUrl = manualUrl, |
| | 0 | 933 | | OneCResult = oneCResult, |
| | 0 | 934 | | }; |
| | | 935 | | } |
| | 0 | 936 | | catch (Exception ex) |
| | | 937 | | { |
| | 0 | 938 | | _logger.LogError(ex, "AI ShippedBySupplier order=#{Id} failed", orderId); |
| | 0 | 939 | | return Fail($"Ошибка при создании транзакции: {ex.Message}"); |
| | | 940 | | } |
| | 0 | 941 | | } |
| | | 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 | | { |
| | 0 | 952 | | TransactionEndpoints.TryGetValue(TransactionType.ShippedToCommissioner, out var endpoint); |
| | 0 | 953 | | var manualUrl = endpoint != null ? $"{endpoint}?ids={orderId}&page=1" : null; |
| | | 954 | | |
| | | 955 | | try |
| | | 956 | | { |
| | 0 | 957 | | var order = await _db.Orders |
| | 0 | 958 | | .Include(o => o.AppStatus) |
| | 0 | 959 | | .Include(o => o.Supplier) |
| | 0 | 960 | | .Include(o => o.ShipmentWarehouse) |
| | 0 | 961 | | .Include(o => o.ClientStatus) |
| | 0 | 962 | | .Include(o => o.OzonClient) |
| | 0 | 963 | | .Include(o => o.OrderExtensions) |
| | 0 | 964 | | .FirstOrDefaultAsync(o => o.Id == orderId, ct); |
| | | 965 | | |
| | 0 | 966 | | if (order == null) |
| | 0 | 967 | | return Fail($"Заказ #{orderId} не найден."); |
| | | 968 | | |
| | 0 | 969 | | var oldStatus = order.AppStatus?.Name ?? "—"; |
| | 0 | 970 | | var oldStatusId = order.AppStatusId; |
| | 0 | 971 | | var historyAuthor = FormatHistoryAuthor(userName, model); |
| | | 972 | | |
| | | 973 | | // Применяем цену если передана |
| | 0 | 974 | | if (dto.PurchasePrice is > 0) |
| | | 975 | | { |
| | 0 | 976 | | order.PurchasePrice = dto.PurchasePrice; |
| | 0 | 977 | | order.CostPrice = dto.PurchasePrice; |
| | 0 | 978 | | _db.OrderHistories.Add(new OrderHistory |
| | 0 | 979 | | { |
| | 0 | 980 | | OrderId = orderId, |
| | 0 | 981 | | ColumnName = "PurchasePrice", |
| | 0 | 982 | | ColumnDisplayName = "Цена закупки", |
| | 0 | 983 | | OldValue = (order.PurchasePrice ?? order.CostPrice ?? 0).ToString(), |
| | 0 | 984 | | NewValue = dto.PurchasePrice.Value.ToString(), |
| | 0 | 985 | | ChangedAt = DateTime.UtcNow, |
| | 0 | 986 | | ChangedBy = historyAuthor, |
| | 0 | 987 | | }); |
| | | 988 | | } |
| | | 989 | | |
| | | 990 | | // Повторная валидация |
| | 0 | 991 | | var preValidation = GetOrderLevelMissingFields(TransactionType.ShippedToCommissioner, order); |
| | 0 | 992 | | if (preValidation.Count > 0) |
| | 0 | 993 | | return new AiExecuteActionResponse |
| | 0 | 994 | | { |
| | 0 | 995 | | Success = false, |
| | 0 | 996 | | Message = $"Транзакция заблокирована:\n• {string.Join("\n• ", preValidation)}", |
| | 0 | 997 | | ManualPageUrl = manualUrl, |
| | 0 | 998 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 999 | | }; |
| | | 1000 | | |
| | | 1001 | | // Особая ветка: если текущий статус «Отгружен реализатором» — |
| | | 1002 | | // только смена статуса, без 1С и без записи Transaction |
| | 0 | 1003 | | bool wasShippedToSeller = oldStatus == "Отгружен реализатором"; |
| | | 1004 | | |
| | 0 | 1005 | | var newAppStatus = await _db.AppStatuses.AsNoTracking() |
| | 0 | 1006 | | .FirstOrDefaultAsync(s => s.Name == "Отгружен комиссионеру", ct); |
| | | 1007 | | |
| | 0 | 1008 | | if (newAppStatus != null) |
| | | 1009 | | { |
| | 0 | 1010 | | order.AppStatusId = newAppStatus.Id; |
| | 0 | 1011 | | order.LastStatusChangeDate = DateTime.Now; |
| | 0 | 1012 | | _db.OrderHistories.Add(new OrderHistory |
| | 0 | 1013 | | { |
| | 0 | 1014 | | OrderId = orderId, |
| | 0 | 1015 | | ColumnName = "AppStatusId", |
| | 0 | 1016 | | ColumnDisplayName = "Статус", |
| | 0 | 1017 | | OldValue = oldStatus, |
| | 0 | 1018 | | NewValue = newAppStatus.Name, |
| | 0 | 1019 | | ChangedAt = DateTime.UtcNow, |
| | 0 | 1020 | | ChangedBy = historyAuthor, |
| | 0 | 1021 | | }); |
| | | 1022 | | } |
| | | 1023 | | |
| | 0 | 1024 | | order.IsAccepted = true; |
| | 0 | 1025 | | order.UpdatedBy = historyAuthor; |
| | 0 | 1026 | | await _db.SaveChangesAsync(ct); |
| | 0 | 1027 | | await RecalcProfitAsync(orderId, ct); |
| | | 1028 | | |
| | | 1029 | | // Если был «Отгружен реализатором» — только смена статуса |
| | 0 | 1030 | | if (wasShippedToSeller) |
| | | 1031 | | { |
| | 0 | 1032 | | _logger.LogInformation("AI ShippedToCommissioner (from ShippedToSeller) order=#{OrderId}: status-only", |
| | 0 | 1033 | | return new AiExecuteActionResponse |
| | 0 | 1034 | | { |
| | 0 | 1035 | | Success = true, |
| | 0 | 1036 | | Message = $"Статус изменён: «{oldStatus}» → «Отгружен комиссионеру».\n" + |
| | 0 | 1037 | | "Заказ был «Отгружен реализатором» — 1С не проводится (только смена статуса).", |
| | 0 | 1038 | | NewStatus = newAppStatus?.Name, |
| | 0 | 1039 | | ShipmentNumber = order.ShipmentNumber, |
| | 0 | 1040 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 1041 | | ManualPageUrl = manualUrl, |
| | 0 | 1042 | | OneCTransferIcon = true, |
| | 0 | 1043 | | }; |
| | | 1044 | | } |
| | | 1045 | | |
| | | 1046 | | // Обычный поток: создаём Transaction + опциональная 1С |
| | 0 | 1047 | | int transactionId = 0; |
| | 0 | 1048 | | string? oneCResult = null; |
| | | 1049 | | |
| | 0 | 1050 | | if (dto.ProcessIn1C) |
| | | 1051 | | { |
| | | 1052 | | try |
| | | 1053 | | { |
| | 0 | 1054 | | var orderFor1C = await _db.Orders |
| | 0 | 1055 | | .Include(o => o.Supplier) |
| | 0 | 1056 | | .Include(o => o.OzonClient) |
| | 0 | 1057 | | .Include(o => o.OrderExtensions) |
| | 0 | 1058 | | .Include(o => o.Manufacturer) |
| | 0 | 1059 | | .Include(o => o.ProductInfo) |
| | 0 | 1060 | | .Include(o => o.ShipmentWarehouse) |
| | 0 | 1061 | | .FirstOrDefaultAsync(o => o.Id == orderId, ct); |
| | | 1062 | | |
| | 0 | 1063 | | if (orderFor1C != null) |
| | | 1064 | | { |
| | 0 | 1065 | | var responses = await _oneCTransferManager.TransferStockByWarehousesWithReversedWarehouses( |
| | 0 | 1066 | | [orderFor1C], |
| | 0 | 1067 | | TransactionType.ShippedToCommissioner, |
| | 0 | 1068 | | documentDate: DateTime.Now, |
| | 0 | 1069 | | userName: userName); |
| | | 1070 | | |
| | 0 | 1071 | | var hasFailure = responses.Any(r => !r.Success); |
| | 0 | 1072 | | if (hasFailure) |
| | | 1073 | | { |
| | | 1074 | | // Rollback |
| | 0 | 1075 | | var orderToRevert = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct); |
| | 0 | 1076 | | if (orderToRevert != null) |
| | | 1077 | | { |
| | 0 | 1078 | | orderToRevert.AppStatusId = oldStatusId; |
| | 0 | 1079 | | orderToRevert.LastStatusChangeDate = DateTime.Now; |
| | 0 | 1080 | | orderToRevert.IsAccepted = false; |
| | 0 | 1081 | | orderToRevert.UpdatedBy = historyAuthor; |
| | 0 | 1082 | | _db.OrderHistories.Add(new OrderHistory |
| | 0 | 1083 | | { |
| | 0 | 1084 | | OrderId = orderId, |
| | 0 | 1085 | | ColumnName = "AppStatusId", |
| | 0 | 1086 | | ColumnDisplayName = "Статус", |
| | 0 | 1087 | | OldValue = "Отгружен комиссионеру", |
| | 0 | 1088 | | NewValue = oldStatus, |
| | 0 | 1089 | | ChangedAt = DateTime.UtcNow, |
| | 0 | 1090 | | ChangedBy = historyAuthor, |
| | 0 | 1091 | | }); |
| | | 1092 | | } |
| | 0 | 1093 | | await _db.SaveChangesAsync(ct); |
| | | 1094 | | |
| | 0 | 1095 | | var failMessages = responses.Where(r => !r.Success).Select(r => r.Message ?? "Неизвестная ош |
| | 0 | 1096 | | return new AiExecuteActionResponse |
| | 0 | 1097 | | { |
| | 0 | 1098 | | Success = false, |
| | 0 | 1099 | | Message = $"Ошибка при проводке в 1С — транзакция отменена (rollback):\n• {string. |
| | 0 | 1100 | | ManualPageUrl = manualUrl, |
| | 0 | 1101 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 1102 | | }; |
| | | 1103 | | } |
| | | 1104 | | |
| | 0 | 1105 | | oneCResult = string.Join("\n", responses.Where(r => r.Success).Select(r => |
| | 0 | 1106 | | { |
| | 0 | 1107 | | var num = r.DocumentNumber != null ? $" №{r.DocumentNumber}" : ""; |
| | 0 | 1108 | | var miss = !string.IsNullOrWhiteSpace(r.MissingNomenclature) |
| | 0 | 1109 | | ? $" (черновик, не найдено: {r.MissingNomenclature})" |
| | 0 | 1110 | | : (r.IsPosted ? " (проведено)" : " (черновик)"); |
| | 0 | 1111 | | return $"✅ Перемещение создано{num}{miss}"; |
| | 0 | 1112 | | })); |
| | 0 | 1113 | | if (string.IsNullOrWhiteSpace(oneCResult)) |
| | 0 | 1114 | | oneCResult = "Результат из 1С не получен."; |
| | 0 | 1115 | | } |
| | 0 | 1116 | | } |
| | 0 | 1117 | | catch (Exception ex1C) |
| | | 1118 | | { |
| | 0 | 1119 | | _logger.LogWarning(ex1C, "AI ShippedToCommissioner: 1C transfer failed for order #{Id} (rollback)", |
| | | 1120 | | try |
| | | 1121 | | { |
| | 0 | 1122 | | var orderToRevert = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct); |
| | 0 | 1123 | | if (orderToRevert != null) |
| | | 1124 | | { |
| | 0 | 1125 | | orderToRevert.AppStatusId = oldStatusId; |
| | 0 | 1126 | | orderToRevert.LastStatusChangeDate = DateTime.Now; |
| | 0 | 1127 | | orderToRevert.IsAccepted = false; |
| | 0 | 1128 | | orderToRevert.UpdatedBy = historyAuthor; |
| | | 1129 | | } |
| | 0 | 1130 | | await _db.SaveChangesAsync(ct); |
| | 0 | 1131 | | } |
| | 0 | 1132 | | catch (Exception rollbackEx) |
| | | 1133 | | { |
| | 0 | 1134 | | _logger.LogError(rollbackEx, "AI ShippedToCommissioner: rollback also failed for order #{Id}", o |
| | 0 | 1135 | | } |
| | | 1136 | | |
| | 0 | 1137 | | return new AiExecuteActionResponse |
| | 0 | 1138 | | { |
| | 0 | 1139 | | Success = false, |
| | 0 | 1140 | | Message = $"Ошибка при создании перемещения в 1С — транзакция отменена (rollback): {ex1C.M |
| | 0 | 1141 | | ManualPageUrl = manualUrl, |
| | 0 | 1142 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 1143 | | }; |
| | | 1144 | | } |
| | | 1145 | | } |
| | | 1146 | | |
| | | 1147 | | // Создаём запись транзакции |
| | 0 | 1148 | | var (_, _, txId) = await _transactionManager.CreateShippedToCommissionerTransaction( |
| | 0 | 1149 | | [order], userName, DateTime.Now, dto.Comment ?? "AI-ассистент"); |
| | 0 | 1150 | | transactionId = txId; |
| | | 1151 | | |
| | 0 | 1152 | | _logger.LogInformation("AI ShippedToCommissioner tx=#{TxId} order=#{OrderId} processIn1C={Flag}", |
| | 0 | 1153 | | transactionId, orderId, dto.ProcessIn1C); |
| | | 1154 | | |
| | 0 | 1155 | | var txInfo = transactionId > 0 ? $" Транзакция #{transactionId}." : ""; |
| | 0 | 1156 | | var oneCInfo = oneCResult != null ? $"\n\n**1С:** {oneCResult}" : ""; |
| | | 1157 | | |
| | 0 | 1158 | | return new AiExecuteActionResponse |
| | 0 | 1159 | | { |
| | 0 | 1160 | | Success = true, |
| | 0 | 1161 | | Message = $"Транзакция «Отгружен комиссионеру» создана.{txInfo}" + |
| | 0 | 1162 | | $"\nСтатус: «{oldStatus}» → «Отгружен комиссионеру»" + oneCInfo, |
| | 0 | 1163 | | NewStatus = newAppStatus?.Name, |
| | 0 | 1164 | | ShipmentNumber = order.ShipmentNumber, |
| | 0 | 1165 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 1166 | | TransactionPageUrl = transactionId > 0 ? $"/Studio2/Transactions/Details/{transactionId}" : null, |
| | 0 | 1167 | | ManualPageUrl = manualUrl, |
| | 0 | 1168 | | OneCResult = oneCResult, |
| | 0 | 1169 | | OneCTransferIcon = true, |
| | 0 | 1170 | | }; |
| | | 1171 | | } |
| | 0 | 1172 | | catch (Exception ex) |
| | | 1173 | | { |
| | 0 | 1174 | | _logger.LogError(ex, "AI ShippedToCommissioner order=#{Id} failed", orderId); |
| | 0 | 1175 | | return Fail($"Ошибка при создании транзакции: {ex.Message}"); |
| | | 1176 | | } |
| | 0 | 1177 | | } |
| | | 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 | | { |
| | 0 | 1188 | | TransactionEndpoints.TryGetValue(TransactionType.ShippedToSeller, out var endpoint); |
| | 0 | 1189 | | var manualUrl = endpoint != null ? $"{endpoint}?ids={orderId}&page=1" : null; |
| | | 1190 | | |
| | | 1191 | | try |
| | | 1192 | | { |
| | 0 | 1193 | | var order = await _db.Orders |
| | 0 | 1194 | | .Include(o => o.AppStatus) |
| | 0 | 1195 | | .Include(o => o.Supplier) |
| | 0 | 1196 | | .Include(o => o.ShipmentWarehouse) |
| | 0 | 1197 | | .Include(o => o.ClientStatus) |
| | 0 | 1198 | | .Include(o => o.OzonClient) |
| | 0 | 1199 | | .Include(o => o.OrderExtensions) |
| | 0 | 1200 | | .FirstOrDefaultAsync(o => o.Id == orderId, ct); |
| | | 1201 | | |
| | 0 | 1202 | | if (order == null) |
| | 0 | 1203 | | return Fail($"Заказ #{orderId} не найден."); |
| | | 1204 | | |
| | 0 | 1205 | | var oldStatus = order.AppStatus?.Name ?? "—"; |
| | 0 | 1206 | | var oldStatusId = order.AppStatusId; |
| | 0 | 1207 | | var historyAuthor = FormatHistoryAuthor(userName, model); |
| | | 1208 | | |
| | | 1209 | | // Применяем цену если передана |
| | 0 | 1210 | | if (dto.PurchasePrice is > 0) |
| | | 1211 | | { |
| | 0 | 1212 | | order.PurchasePrice = dto.PurchasePrice; |
| | 0 | 1213 | | order.CostPrice = dto.PurchasePrice; |
| | 0 | 1214 | | _db.OrderHistories.Add(new OrderHistory |
| | 0 | 1215 | | { |
| | 0 | 1216 | | OrderId = orderId, |
| | 0 | 1217 | | ColumnName = "PurchasePrice", |
| | 0 | 1218 | | ColumnDisplayName = "Цена закупки", |
| | 0 | 1219 | | OldValue = (order.PurchasePrice ?? order.CostPrice ?? 0).ToString(), |
| | 0 | 1220 | | NewValue = dto.PurchasePrice.Value.ToString(), |
| | 0 | 1221 | | ChangedAt = DateTime.UtcNow, |
| | 0 | 1222 | | ChangedBy = historyAuthor, |
| | 0 | 1223 | | }); |
| | | 1224 | | } |
| | | 1225 | | |
| | | 1226 | | // Повторная валидация |
| | 0 | 1227 | | var preValidation = GetOrderLevelMissingFields(TransactionType.ShippedToSeller, order); |
| | 0 | 1228 | | if (preValidation.Count > 0) |
| | 0 | 1229 | | return new AiExecuteActionResponse |
| | 0 | 1230 | | { |
| | 0 | 1231 | | Success = false, |
| | 0 | 1232 | | Message = $"Транзакция заблокирована:\n• {string.Join("\n• ", preValidation)}", |
| | 0 | 1233 | | ManualPageUrl = manualUrl, |
| | 0 | 1234 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 1235 | | }; |
| | | 1236 | | |
| | 0 | 1237 | | var qty = order.Quantity ?? 0; |
| | 0 | 1238 | | bool isLost = qty <= 0; |
| | | 1239 | | |
| | | 1240 | | // Определяем целевой статус |
| | 0 | 1241 | | var targetStatusName = isLost ? "Утерян реализатором" : "Отгружен реализатором"; |
| | 0 | 1242 | | var newAppStatus = await _db.AppStatuses.AsNoTracking() |
| | 0 | 1243 | | .FirstOrDefaultAsync(s => s.Name == targetStatusName, ct); |
| | | 1244 | | |
| | 0 | 1245 | | if (newAppStatus != null) |
| | | 1246 | | { |
| | 0 | 1247 | | order.AppStatusId = newAppStatus.Id; |
| | 0 | 1248 | | order.LastStatusChangeDate = DateTime.Now; |
| | 0 | 1249 | | _db.OrderHistories.Add(new OrderHistory |
| | 0 | 1250 | | { |
| | 0 | 1251 | | OrderId = orderId, |
| | 0 | 1252 | | ColumnName = "AppStatusId", |
| | 0 | 1253 | | ColumnDisplayName = "Статус", |
| | 0 | 1254 | | OldValue = oldStatus, |
| | 0 | 1255 | | NewValue = newAppStatus.Name, |
| | 0 | 1256 | | ChangedAt = DateTime.UtcNow, |
| | 0 | 1257 | | ChangedBy = historyAuthor, |
| | 0 | 1258 | | }); |
| | | 1259 | | } |
| | | 1260 | | |
| | 0 | 1261 | | order.IsAccepted = true; |
| | 0 | 1262 | | order.UpdatedBy = historyAuthor; |
| | 0 | 1263 | | await _db.SaveChangesAsync(ct); |
| | 0 | 1264 | | await RecalcProfitAsync(orderId, ct); |
| | | 1265 | | |
| | | 1266 | | // Создаём запись транзакции |
| | 0 | 1267 | | int transactionId = 0; |
| | 0 | 1268 | | if (isLost) |
| | | 1269 | | { |
| | 0 | 1270 | | var (_, _, txId) = await _transactionManager.CreateLostByCommissionerTransaction( |
| | 0 | 1271 | | [order], userName, DateTime.Now, dto.Comment ?? "AI-ассистент"); |
| | 0 | 1272 | | transactionId = txId; |
| | | 1273 | | } |
| | | 1274 | | else |
| | | 1275 | | { |
| | 0 | 1276 | | var (_, _, txId) = await _transactionManager.CreateShippedToSellerTransaction( |
| | 0 | 1277 | | [order], userName, DateTime.Now, dto.Comment ?? "AI-ассистент"); |
| | 0 | 1278 | | transactionId = txId; |
| | | 1279 | | } |
| | | 1280 | | |
| | 0 | 1281 | | _logger.LogInformation("AI ShippedToSeller tx=#{TxId} order=#{OrderId} isLost={IsLost} processIn1C={Flag}", |
| | 0 | 1282 | | transactionId, orderId, isLost, dto.ProcessIn1C); |
| | | 1283 | | |
| | | 1284 | | // ── 1С-проводка (всегда, с rollback при ошибке) ─────────────────── |
| | 0 | 1285 | | string? oneCResult = null; |
| | | 1286 | | { |
| | | 1287 | | try |
| | | 1288 | | { |
| | 0 | 1289 | | var orderFor1C = await _db.Orders |
| | 0 | 1290 | | .Include(o => o.Supplier) |
| | 0 | 1291 | | .Include(o => o.OzonClient) |
| | 0 | 1292 | | .Include(o => o.OrderExtensions) |
| | 0 | 1293 | | .Include(o => o.Manufacturer) |
| | 0 | 1294 | | .Include(o => o.ProductInfo) |
| | 0 | 1295 | | .Include(o => o.ShipmentWarehouse) |
| | 0 | 1296 | | .FirstOrDefaultAsync(o => o.Id == orderId, ct); |
| | | 1297 | | |
| | 0 | 1298 | | if (orderFor1C != null) |
| | | 1299 | | { |
| | | 1300 | | List<OneCResponse> responses; |
| | 0 | 1301 | | if (isLost) |
| | | 1302 | | { |
| | 0 | 1303 | | responses = await _oneCTransferManager.TransferStockToTransitWarehouses( |
| | 0 | 1304 | | [orderFor1C], |
| | 0 | 1305 | | documentDate: DateTime.Now, |
| | 0 | 1306 | | userName: userName); |
| | | 1307 | | } |
| | | 1308 | | else |
| | | 1309 | | { |
| | 0 | 1310 | | responses = await _oneCTransferManager.TransferStockByWarehouses( |
| | 0 | 1311 | | [orderFor1C], |
| | 0 | 1312 | | TransactionType.ShippedToSeller, |
| | 0 | 1313 | | documentDate: DateTime.Now, |
| | 0 | 1314 | | userName: userName); |
| | | 1315 | | } |
| | | 1316 | | |
| | 0 | 1317 | | var hasFailure = responses.Any(r => !r.Success); |
| | 0 | 1318 | | if (hasFailure) |
| | | 1319 | | { |
| | | 1320 | | // Rollback: восстанавливаем статус и удаляем транзакцию |
| | 0 | 1321 | | var orderToRevert = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct); |
| | 0 | 1322 | | if (orderToRevert != null) |
| | | 1323 | | { |
| | 0 | 1324 | | orderToRevert.AppStatusId = oldStatusId; |
| | 0 | 1325 | | orderToRevert.LastStatusChangeDate = DateTime.Now; |
| | 0 | 1326 | | orderToRevert.IsAccepted = false; |
| | 0 | 1327 | | orderToRevert.UpdatedBy = historyAuthor; |
| | 0 | 1328 | | _db.OrderHistories.Add(new OrderHistory |
| | 0 | 1329 | | { |
| | 0 | 1330 | | OrderId = orderId, |
| | 0 | 1331 | | ColumnName = "AppStatusId", |
| | 0 | 1332 | | ColumnDisplayName = "Статус", |
| | 0 | 1333 | | OldValue = targetStatusName, |
| | 0 | 1334 | | NewValue = oldStatus, |
| | 0 | 1335 | | ChangedAt = DateTime.UtcNow, |
| | 0 | 1336 | | ChangedBy = historyAuthor, |
| | 0 | 1337 | | }); |
| | | 1338 | | } |
| | | 1339 | | |
| | 0 | 1340 | | if (transactionId > 0) |
| | | 1341 | | { |
| | 0 | 1342 | | var txType = isLost ? "LostByCommissioner" : "ShippedToSeller"; |
| | 0 | 1343 | | var txRecord = await _db.Transactions |
| | 0 | 1344 | | .FirstOrDefaultAsync(t => t.Id == transactionId, ct); |
| | 0 | 1345 | | if (txRecord != null) |
| | 0 | 1346 | | _db.Transactions.Remove(txRecord); |
| | 0 | 1347 | | _logger.LogWarning("AI ShippedToSeller: 1C failed, rolling back tx=#{TxId} ({Type}) orde |
| | 0 | 1348 | | transactionId, txType, orderId); |
| | 0 | 1349 | | } |
| | | 1350 | | |
| | 0 | 1351 | | await _db.SaveChangesAsync(ct); |
| | | 1352 | | |
| | 0 | 1353 | | var failMessages = responses |
| | 0 | 1354 | | .Where(r => !r.Success) |
| | 0 | 1355 | | .Select(r => r.Message ?? "Неизвестная ошибка"); |
| | 0 | 1356 | | return new AiExecuteActionResponse |
| | 0 | 1357 | | { |
| | 0 | 1358 | | Success = false, |
| | 0 | 1359 | | Message = $"Ошибка при проводке в 1С — транзакция отменена (rollback):\n• {string. |
| | 0 | 1360 | | ManualPageUrl = manualUrl, |
| | 0 | 1361 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 1362 | | }; |
| | | 1363 | | } |
| | | 1364 | | |
| | 0 | 1365 | | var successParts = responses |
| | 0 | 1366 | | .Where(r => r.Success) |
| | 0 | 1367 | | .Select(r => |
| | 0 | 1368 | | { |
| | 0 | 1369 | | var num = r.DocumentNumber != null ? $" №{r.DocumentNumber}" : ""; |
| | 0 | 1370 | | var miss = !string.IsNullOrWhiteSpace(r.MissingNomenclature) |
| | 0 | 1371 | | ? $" (черновик, не найдено: {r.MissingNomenclature})" |
| | 0 | 1372 | | : (r.IsPosted ? " (проведено)" : " (черновик)"); |
| | 0 | 1373 | | return $"✅ Перемещение создано{num}{miss}"; |
| | 0 | 1374 | | }); |
| | 0 | 1375 | | oneCResult = string.Join("\n", successParts); |
| | 0 | 1376 | | if (string.IsNullOrWhiteSpace(oneCResult)) |
| | 0 | 1377 | | oneCResult = "Результат из 1С не получен."; |
| | 0 | 1378 | | } |
| | 0 | 1379 | | } |
| | 0 | 1380 | | catch (Exception ex1C) |
| | | 1381 | | { |
| | 0 | 1382 | | _logger.LogWarning(ex1C, "AI ShippedToSeller: 1C transfer failed for order #{Id} (rollback)", orderI |
| | | 1383 | | |
| | | 1384 | | // Rollback при исключении |
| | | 1385 | | try |
| | | 1386 | | { |
| | 0 | 1387 | | var orderToRevert = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct); |
| | 0 | 1388 | | if (orderToRevert != null) |
| | | 1389 | | { |
| | 0 | 1390 | | orderToRevert.AppStatusId = oldStatusId; |
| | 0 | 1391 | | orderToRevert.LastStatusChangeDate = DateTime.Now; |
| | 0 | 1392 | | orderToRevert.IsAccepted = false; |
| | 0 | 1393 | | orderToRevert.UpdatedBy = historyAuthor; |
| | | 1394 | | } |
| | 0 | 1395 | | if (transactionId > 0) |
| | | 1396 | | { |
| | 0 | 1397 | | var txRecord = await _db.Transactions.FirstOrDefaultAsync(t => t.Id == transactionId, ct); |
| | 0 | 1398 | | if (txRecord != null) |
| | 0 | 1399 | | _db.Transactions.Remove(txRecord); |
| | | 1400 | | } |
| | 0 | 1401 | | await _db.SaveChangesAsync(ct); |
| | 0 | 1402 | | } |
| | 0 | 1403 | | catch (Exception rollbackEx) |
| | | 1404 | | { |
| | 0 | 1405 | | _logger.LogError(rollbackEx, "AI ShippedToSeller: rollback also failed for order #{Id}", orderId |
| | 0 | 1406 | | } |
| | | 1407 | | |
| | 0 | 1408 | | return new AiExecuteActionResponse |
| | 0 | 1409 | | { |
| | 0 | 1410 | | Success = false, |
| | 0 | 1411 | | Message = $"Ошибка при создании перемещения в 1С — транзакция отменена (rollback): {ex1C.M |
| | 0 | 1412 | | ManualPageUrl = manualUrl, |
| | 0 | 1413 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 1414 | | }; |
| | | 1415 | | } |
| | | 1416 | | } |
| | | 1417 | | |
| | 0 | 1418 | | var txLabel = isLost ? "Утерян реализатором" : "Отгружен реализатором"; |
| | 0 | 1419 | | var statusInfo = newAppStatus != null ? $"«{oldStatus}» → «{newAppStatus.Name}»" : ""; |
| | 0 | 1420 | | var txInfo = transactionId > 0 ? $" Транзакция #{transactionId}." : ""; |
| | 0 | 1421 | | var oneCInfo = oneCResult != null ? $"\n\n**1С:** {oneCResult}" : ""; |
| | | 1422 | | |
| | 0 | 1423 | | return new AiExecuteActionResponse |
| | 0 | 1424 | | { |
| | 0 | 1425 | | Success = true, |
| | 0 | 1426 | | Message = $"Транзакция «{txLabel}» создана.{txInfo}" + |
| | 0 | 1427 | | (statusInfo != "" ? $"\nСтатус: {statusInfo}" : "") + oneCInfo, |
| | 0 | 1428 | | NewStatus = newAppStatus?.Name, |
| | 0 | 1429 | | ShipmentNumber = order.ShipmentNumber, |
| | 0 | 1430 | | OrderPageUrl = $"/Orders/Index?ids={orderId}", |
| | 0 | 1431 | | TransactionPageUrl = transactionId > 0 ? $"/Studio2/Transactions/Details/{transactionId}" : null, |
| | 0 | 1432 | | ManualPageUrl = manualUrl, |
| | 0 | 1433 | | OneCResult = oneCResult, |
| | 0 | 1434 | | OneCTransferIcon = true, |
| | 0 | 1435 | | }; |
| | | 1436 | | } |
| | 0 | 1437 | | catch (Exception ex) |
| | | 1438 | | { |
| | 0 | 1439 | | _logger.LogError(ex, "AI ShippedToSeller order=#{Id} failed", orderId); |
| | 0 | 1440 | | return Fail($"Ошибка при создании транзакции: {ex.Message}"); |
| | | 1441 | | } |
| | 0 | 1442 | | } |
| | | 1443 | | |
| | | 1444 | | // ── Вспомогательные методы ───────────────────────────────────────────────── |
| | | 1445 | | |
| | | 1446 | | private async Task<Order?> LoadOrderAsync(int orderId, string? userId, CancellationToken ct) |
| | | 1447 | | { |
| | 0 | 1448 | | var query = _db.Orders.AsNoTracking() |
| | 0 | 1449 | | .Include(o => o.AppStatus) |
| | 0 | 1450 | | .Include(o => o.Supplier) |
| | 0 | 1451 | | .Include(o => o.ShipmentWarehouse) |
| | 0 | 1452 | | .Include(o => o.ClientStatus) |
| | 0 | 1453 | | .Where(o => o.Id == orderId); |
| | | 1454 | | |
| | 0 | 1455 | | query = await _scopeApplier.ApplyAsync(query, userId, ct); |
| | 0 | 1456 | | return await query.FirstOrDefaultAsync(ct); |
| | 0 | 1457 | | } |
| | | 1458 | | |
| | | 1459 | | private async Task<AppStatus?> FindStatusFuzzyAsync(string name, CancellationToken ct) |
| | | 1460 | | { |
| | 0 | 1461 | | var statuses = await _appStatuses.GetAppStatuses(); |
| | 0 | 1462 | | return statuses.FirstOrDefault(s => |
| | 0 | 1463 | | s.Name != null && |
| | 0 | 1464 | | (s.Name.Equals(name, StringComparison.OrdinalIgnoreCase) || |
| | 0 | 1465 | | s.Name.Contains(name, StringComparison.OrdinalIgnoreCase))); |
| | 0 | 1466 | | } |
| | | 1467 | | |
| | | 1468 | | private async Task<OzonDomains.Models.Supplier?> FindSupplierFuzzyAsync(string name, CancellationToken ct) |
| | | 1469 | | { |
| | 0 | 1470 | | var suppliers = await _suppliers.GetSuppliers(); |
| | 0 | 1471 | | return suppliers.FirstOrDefault(s => |
| | 0 | 1472 | | s.Name != null && |
| | 0 | 1473 | | (s.Name.Equals(name, StringComparison.OrdinalIgnoreCase) || |
| | 0 | 1474 | | s.Name.Contains(name, StringComparison.OrdinalIgnoreCase))); |
| | 0 | 1475 | | } |
| | | 1476 | | |
| | | 1477 | | private async Task<OzonDomains.Models.Warehouse?> FindWarehouseFuzzyAsync(string name, CancellationToken ct) |
| | | 1478 | | { |
| | 0 | 1479 | | var warehouses = await _warehouses.GetWarehouses(); |
| | 0 | 1480 | | return warehouses.FirstOrDefault(w => |
| | 0 | 1481 | | w.Name != null && |
| | 0 | 1482 | | (w.Name.Equals(name, StringComparison.OrdinalIgnoreCase) || |
| | 0 | 1483 | | w.Name.Contains(name, StringComparison.OrdinalIgnoreCase))); |
| | 0 | 1484 | | } |
| | | 1485 | | |
| | 0 | 1486 | | private static string? GetTargetStatus(TransactionType type) => type switch |
| | 0 | 1487 | | { |
| | 0 | 1488 | | TransactionType.OrderedToSupplier => "Заказан поставщику", |
| | 0 | 1489 | | TransactionType.OrderedToSeller => "Заказан реализатору", |
| | 0 | 1490 | | TransactionType.AcceptedToWarehouse => "ПринятНаСкладе", |
| | 0 | 1491 | | TransactionType.ToCancellation => "К отмене", |
| | 0 | 1492 | | TransactionType.Cancelled => "Отменен", |
| | 0 | 1493 | | TransactionType.WarehouseChange => null, // не меняет статус |
| | 0 | 1494 | | TransactionType.ReturnTransfer => "Перемещение", |
| | 0 | 1495 | | _ => null |
| | 0 | 1496 | | }; |
| | | 1497 | | |
| | | 1498 | | private Task<(int, string, int)> CreateTransactionByTypeAsync( |
| | | 1499 | | TransactionType type, |
| | | 1500 | | List<Order> orders, |
| | | 1501 | | string userName, |
| | | 1502 | | DateTime createDateTime, |
| | 0 | 1503 | | string comment) => type switch |
| | 0 | 1504 | | { |
| | 0 | 1505 | | TransactionType.OrderedToSupplier => _transactionManager.CreateOrderToSupplierTransaction(orders, userName, cr |
| | 0 | 1506 | | TransactionType.OrderedToSeller => _transactionManager.CreateOrderedToSellerTransaction(orders, userName, cr |
| | 0 | 1507 | | TransactionType.ToCancellation => _transactionManager.CreateToCancellationTransaction(orders, userName, cre |
| | 0 | 1508 | | TransactionType.Cancelled => _transactionManager.CreateCancelledTransaction(orders, userName, createDa |
| | 0 | 1509 | | TransactionType.WarehouseChange => _transactionManager.CreateWarehouseChangeTransaction(orders, userName, cr |
| | 0 | 1510 | | TransactionType.ReturnTransfer => _transactionManager.CreateReturnTransferTransaction(orders, userName, cre |
| | 0 | 1511 | | TransactionType.Percentage => _transactionManager.CreatePercentageTransaction(orders, userName, createD |
| | 0 | 1512 | | // Для транзакций с 1С (не должны доходить до этого метода, но на всякий случай): |
| | 0 | 1513 | | TransactionType.AcceptedToWarehouse => _transactionManager.AcceptedToWarehouseTransaction(orders, userName, crea |
| | 0 | 1514 | | TransactionType.WarehouseTransfer => _transactionManager.CreateWarehouseTransferTransaction(orders, userName, |
| | 0 | 1515 | | _ => throw new InvalidOperationException($"Transaction type {type} not support |
| | 0 | 1516 | | }; |
| | | 1517 | | |
| | | 1518 | | /// <summary> |
| | | 1519 | | /// Форматирует строку автора для orders_history: |
| | | 1520 | | /// "Иван Иванов [AI Стас: deepseek/deepseek-v4-flash]" |
| | | 1521 | | /// </summary> |
| | | 1522 | | private static string FormatHistoryAuthor(string userName, string? model) |
| | | 1523 | | { |
| | 0 | 1524 | | if (string.IsNullOrWhiteSpace(model)) |
| | 0 | 1525 | | return $"{userName} [AI Стас]"; |
| | | 1526 | | // Укорачиваем длинный ID модели: берём только часть после "/" |
| | 0 | 1527 | | var shortModel = model.Contains('/') ? model[(model.LastIndexOf('/') + 1)..] : model; |
| | 0 | 1528 | | 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 | | { |
| | 0 | 1540 | | var order = await _db.Orders |
| | 0 | 1541 | | .Include(o => o.Supplier) |
| | 0 | 1542 | | .Include(o => o.ProductInfo) |
| | 0 | 1543 | | .FirstOrDefaultAsync(o => o.Id == orderId, ct); |
| | | 1544 | | |
| | 0 | 1545 | | if (order == null) return; |
| | | 1546 | | |
| | 0 | 1547 | | order = await _priceManager.SetPurchasePriceToRUB(order) ?? order; |
| | 0 | 1548 | | order = await _priceManager.CalculateCostPrice(order) ?? order; |
| | 0 | 1549 | | order = await _priceManager.CalculateProfit(order) ?? order; |
| | 0 | 1550 | | order = await _priceManager.CalculateDiscount(order) ?? order; |
| | | 1551 | | |
| | 0 | 1552 | | await _db.SaveChangesAsync(ct); |
| | 0 | 1553 | | } |
| | 0 | 1554 | | catch (Exception ex) |
| | | 1555 | | { |
| | 0 | 1556 | | _logger.LogWarning(ex, "AI: recalc profit failed for order #{Id} (non-blocking)", orderId); |
| | 0 | 1557 | | } |
| | 0 | 1558 | | } |
| | | 1559 | | |
| | | 1560 | | private static AiExecuteActionResponse Fail(string message) => |
| | 0 | 1561 | | new() { Success = false, Message = message }; |
| | | 1562 | | } |