| | | 1 | | using Services.SystemMonitoringServices; |
| | | 2 | | using Microsoft.Extensions.Logging; |
| | | 3 | | using OzonDomains; |
| | | 4 | | using OzonDomains.Models; |
| | | 5 | | using Services.ApiServices._1CApi.Models; |
| | | 6 | | using Services.ApiServices._1CApi.Nomenclature; |
| | | 7 | | using Services.OneCServices; |
| | | 8 | | using Services.ApiServices._1CApi.OData; |
| | | 9 | | using Services.ApiServices._1CApi.OData.Models; |
| | | 10 | | using Services.BackgroundJobsServices; |
| | | 11 | | using Services.DataServices; |
| | | 12 | | |
| | | 13 | | namespace Services.ApiServices._1CApi; |
| | | 14 | | |
| | | 15 | | public class OneCExpenseManager : IApiDataManager<OneCExpenseManager> |
| | | 16 | | { |
| | | 17 | | private readonly IOneCODataReferenceService _referenceService; |
| | | 18 | | private readonly IOneCODataClient _odataClient; |
| | | 19 | | private readonly IOneCNomenclatureService _nomenclatureService; |
| | | 20 | | private readonly WarehouseMappingDataServices _warehouseMappingDataServices; |
| | | 21 | | private readonly ISystemMonitoringSignalWriter? _signals; |
| | | 22 | | private readonly IOneCPostingFailureReporter? _failureReporter; |
| | | 23 | | private readonly ILogger<OneCExpenseManager> _logger; |
| | | 24 | | private const string ZasylKey = "Засыл"; |
| | | 25 | | private const string ExpenseEndpoint = "Document_РасходнаяНакладная"; |
| | | 26 | | |
| | 0 | 27 | | public OneCExpenseManager( |
| | 0 | 28 | | IOneCODataReferenceService referenceService, |
| | 0 | 29 | | IOneCODataClient odataClient, |
| | 0 | 30 | | IOneCNomenclatureService nomenclatureService, |
| | 0 | 31 | | WarehouseMappingDataServices warehouseMappingDataServices, |
| | 0 | 32 | | ILogger<OneCExpenseManager> logger, |
| | 0 | 33 | | ISystemMonitoringSignalWriter? signals = null, |
| | 0 | 34 | | IOneCPostingFailureReporter? failureReporter = null) |
| | | 35 | | { |
| | 0 | 36 | | _signals = signals; |
| | 0 | 37 | | _failureReporter = failureReporter; |
| | 0 | 38 | | _referenceService = referenceService; |
| | 0 | 39 | | _odataClient = odataClient; |
| | 0 | 40 | | _nomenclatureService = nomenclatureService; |
| | 0 | 41 | | _warehouseMappingDataServices = warehouseMappingDataServices; |
| | 0 | 42 | | _logger = logger; |
| | 0 | 43 | | } |
| | | 44 | | |
| | 0 | 45 | | public OneCExpenseManager SetClient(string user, string password) => this; |
| | | 46 | | |
| | | 47 | | public async Task<List<OneCResponse>> SaleStockByWarehouses( |
| | | 48 | | List<Order> allOrders, |
| | | 49 | | TransactionType transactionType, |
| | | 50 | | string? contractLink = null, |
| | | 51 | | string? transactionComment = null, |
| | | 52 | | int? vatRate = null, |
| | | 53 | | DateTime? documentDate = null, |
| | | 54 | | string? userName = null, |
| | | 55 | | string? orgInnOverride = null, |
| | | 56 | | bool createAsDraft = false, |
| | | 57 | | string? draftReason = null) |
| | | 58 | | { |
| | 0 | 59 | | if (allOrders == null || !allOrders.Any()) |
| | 0 | 60 | | throw new ArgumentException("Список заказов не может быть пустым."); |
| | | 61 | | |
| | 0 | 62 | | var warehouseMappings = await _warehouseMappingDataServices.GetWarehouseMappings(); |
| | 0 | 63 | | var ordersWithWarehouse = allOrders |
| | 0 | 64 | | .Select(o => new |
| | 0 | 65 | | { |
| | 0 | 66 | | Order = o, |
| | 0 | 67 | | Warehouse = ShippedToCommissionerWarehouseResolver.ResolveSenderWarehouse(o, warehouseMappings) |
| | 0 | 68 | | }) |
| | 0 | 69 | | .ToList(); |
| | | 70 | | |
| | 0 | 71 | | var missing = ordersWithWarehouse |
| | 0 | 72 | | .Where(x => string.IsNullOrWhiteSpace(x.Warehouse)) |
| | 0 | 73 | | .Select(x => x.Order.ShipmentNumber) |
| | 0 | 74 | | .ToList(); |
| | 0 | 75 | | if (missing.Any()) |
| | 0 | 76 | | throw new ArgumentException($"Для отправки в 1С у заказов не заполнен склад (засыл/склад): {string.Join(", " |
| | | 77 | | |
| | 0 | 78 | | var allResults = new List<OneCResponse>(); |
| | 0 | 79 | | foreach (var warehouseGroup in ordersWithWarehouse.GroupBy(x => x.Warehouse!)) |
| | | 80 | | { |
| | | 81 | | try |
| | | 82 | | { |
| | 0 | 83 | | var results = await SaleStock( |
| | 0 | 84 | | warehouseGroup.Select(x => x.Order).ToList(), |
| | 0 | 85 | | transactionType, |
| | 0 | 86 | | warehouseGroup.Key, |
| | 0 | 87 | | contractLink, |
| | 0 | 88 | | transactionComment, |
| | 0 | 89 | | vatRate, |
| | 0 | 90 | | documentDate, |
| | 0 | 91 | | userName, |
| | 0 | 92 | | orgInnOverride, |
| | 0 | 93 | | createAsDraft, |
| | 0 | 94 | | draftReason); |
| | 0 | 95 | | allResults.AddRange(results); |
| | 0 | 96 | | } |
| | 0 | 97 | | catch (Exception ex) |
| | | 98 | | { |
| | 0 | 99 | | allResults.Add(new OneCResponse |
| | 0 | 100 | | { |
| | 0 | 101 | | Success = false, |
| | 0 | 102 | | Message = $"Ошибка при обработке склада '{warehouseGroup.Key}': {ex.Message}" |
| | 0 | 103 | | }); |
| | 0 | 104 | | } |
| | 0 | 105 | | } |
| | | 106 | | |
| | 0 | 107 | | return allResults; |
| | 0 | 108 | | } |
| | | 109 | | |
| | | 110 | | public async Task<List<OneCResponse>> SaleStock( |
| | | 111 | | List<Order> ordersToSale, |
| | | 112 | | TransactionType transactionType, |
| | | 113 | | string? zasyl = null, |
| | | 114 | | string? contractLink = null, |
| | | 115 | | string? transactionComment = null, |
| | | 116 | | int? vatRate = null, |
| | | 117 | | DateTime? documentDate = null, |
| | | 118 | | string? userName = null, |
| | | 119 | | string? orgInnOverride = null, |
| | | 120 | | bool createAsDraft = false, |
| | | 121 | | string? draftReason = null) |
| | | 122 | | { |
| | 0 | 123 | | if (ordersToSale == null || !ordersToSale.Any()) |
| | 0 | 124 | | throw new ArgumentException("Список заказов не может быть пустым."); |
| | | 125 | | |
| | | 126 | | string warehouseName; |
| | 0 | 127 | | if (string.IsNullOrEmpty(zasyl)) |
| | | 128 | | { |
| | 0 | 129 | | var distinct = ordersToSale |
| | 0 | 130 | | .Select(GetEffectiveWarehouseName) |
| | 0 | 131 | | .Where(x => !string.IsNullOrWhiteSpace(x)) |
| | 0 | 132 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 0 | 133 | | .ToList(); |
| | | 134 | | |
| | 0 | 135 | | if (distinct.Count > 1) |
| | 0 | 136 | | throw new ArgumentException($"Заказы имеют различные эффективные склады: {string.Join(", ", distinct)}") |
| | 0 | 137 | | if (distinct.Count == 0) |
| | 0 | 138 | | throw new ArgumentException("Не указан эффективный склад для заказов."); |
| | 0 | 139 | | warehouseName = distinct[0]!; |
| | | 140 | | } |
| | | 141 | | else |
| | | 142 | | { |
| | 0 | 143 | | warehouseName = zasyl; |
| | | 144 | | } |
| | | 145 | | |
| | 0 | 146 | | var ordersByClient = ordersToSale |
| | 0 | 147 | | .Where(o => o.OzonClient != null && o.OzonClientId.HasValue) |
| | 0 | 148 | | .GroupBy(o => o.OzonClientId!.Value) |
| | 0 | 149 | | .ToDictionary(g => g.Key, g => g.ToList()); |
| | | 150 | | |
| | 0 | 151 | | if (!ordersByClient.Any()) |
| | 0 | 152 | | throw new ArgumentException("Не найдено заказов с валидными данными клиента Ozon."); |
| | | 153 | | |
| | 0 | 154 | | var results = new List<OneCResponse>(); |
| | 0 | 155 | | foreach (var (_, orders) in ordersByClient) |
| | | 156 | | { |
| | 0 | 157 | | var client = orders.First().OzonClient!; |
| | 0 | 158 | | if (string.IsNullOrEmpty(client.INNCode) || string.IsNullOrEmpty(client.WarehouseName)) |
| | 0 | 159 | | throw new ArgumentException($"Клиент '{client.Name}' не имеет обязательных данных (ИНН или склад)."); |
| | | 160 | | |
| | 0 | 161 | | var comment = BuildComment(transactionType, userName, client.Name, |
| | 0 | 162 | | orders.Select(o => o.ShipmentNumber), transactionComment); |
| | | 163 | | |
| | 0 | 164 | | var result = await CreateExpenseByClientAsync( |
| | 0 | 165 | | client, orders, warehouseName, transactionType, |
| | 0 | 166 | | contractLink, comment, vatRate, documentDate, orgInnOverride, createAsDraft, draftReason); |
| | 0 | 167 | | results.Add(result); |
| | | 168 | | } |
| | | 169 | | |
| | 0 | 170 | | return results; |
| | 0 | 171 | | } |
| | | 172 | | |
| | | 173 | | private async Task<OneCResponse> CreateExpenseByClientAsync( |
| | | 174 | | OzonClient client, |
| | | 175 | | List<Order> orders, |
| | | 176 | | string warehouseName, |
| | | 177 | | TransactionType transactionType, |
| | | 178 | | string? contractLink, |
| | | 179 | | string comment, |
| | | 180 | | int? vatRate, |
| | | 181 | | DateTime? documentDate, |
| | | 182 | | string? orgInnOverride = null, |
| | | 183 | | bool createAsDraft = false, |
| | | 184 | | string? draftReason = null) |
| | | 185 | | { |
| | 0 | 186 | | var response = await CreateExpenseByClientCoreAsync(client, orders, warehouseName, transactionType, contractLink |
| | 0 | 187 | | _failureReporter?.Report(response, $"{transactionType.GetDisplayName()}: расходная накладная, склад {warehouseNa |
| | 0 | 188 | | return response; |
| | 0 | 189 | | } |
| | | 190 | | |
| | | 191 | | private async Task<OneCResponse> CreateExpenseByClientCoreAsync( |
| | | 192 | | OzonClient client, |
| | | 193 | | List<Order> orders, |
| | | 194 | | string warehouseName, |
| | | 195 | | TransactionType transactionType, |
| | | 196 | | string? contractLink, |
| | | 197 | | string comment, |
| | | 198 | | int? vatRate, |
| | | 199 | | DateTime? documentDate, |
| | | 200 | | string? orgInnOverride = null, |
| | | 201 | | bool createAsDraft = false, |
| | | 202 | | string? draftReason = null) |
| | | 203 | | { |
| | | 204 | | const string operationLabel = "создании расходной накладной"; |
| | | 205 | | try |
| | | 206 | | { |
| | 0 | 207 | | var effectiveOrgInn = !string.IsNullOrWhiteSpace(orgInnOverride) ? orgInnOverride.Trim() : "0000000000"; |
| | 0 | 208 | | var orgGuid = await _referenceService.GetOrganizationGuidByInnAsync(effectiveOrgInn); |
| | 0 | 209 | | if (orgGuid == null) |
| | 0 | 210 | | return new OneCResponse |
| | 0 | 211 | | { |
| | 0 | 212 | | Success = false, |
| | 0 | 213 | | Message = $"Организация с ИНН '{effectiveOrgInn}' не найдена в справочнике организаций 1С. Проверьте |
| | 0 | 214 | | }; |
| | | 215 | | |
| | 0 | 216 | | var counterpartyGuid = await _referenceService.GetCounterpartyGuidByInnAsync(client.INNCode ?? ""); |
| | 0 | 217 | | if (counterpartyGuid == null) |
| | 0 | 218 | | return new OneCResponse |
| | 0 | 219 | | { |
| | 0 | 220 | | Success = false, |
| | 0 | 221 | | Message = $"Покупатель с ИНН '{client.INNCode}' не найден в справочнике контрагентов 1С." |
| | 0 | 222 | | }; |
| | | 223 | | |
| | 0 | 224 | | var warehouseGuid = await _referenceService.GetWarehouseGuidByNameAsync(warehouseName) |
| | 0 | 225 | | ?? throw new InvalidOperationException($"Склад '{warehouseName}' не найден в 1С."); |
| | | 226 | | |
| | 0 | 227 | | var effectiveVatGuid = vatRate.HasValue |
| | 0 | 228 | | ? await _referenceService.GetVatRateGuidByRateAsync(vatRate.Value) |
| | 0 | 229 | | ?? await _referenceService.GetNoVatRateGuidAsync() |
| | 0 | 230 | | ?? Guid.Empty |
| | 0 | 231 | | : await _referenceService.GetNoVatRateGuidAsync() |
| | 0 | 232 | | ?? Guid.Empty; |
| | | 233 | | |
| | 0 | 234 | | var missingItems = new List<(string Article, string? ProductName, decimal Qty)>(); |
| | 0 | 235 | | var createdNomenclature = new List<string>(); |
| | 0 | 236 | | var totalAmount = 0m; |
| | | 237 | | |
| | | 238 | | // Номенклатура для разных позиций разрешается параллельно (GetOrCreateManyAsync) — |
| | | 239 | | // последовательные вызовы GetOrCreateAsync в цикле давали 7-11 HTTP-запросов к 1С |
| | | 240 | | // на КАЖДУЮ новую позицию строго друг за другом. |
| | 0 | 241 | | var validOrders = orders |
| | 0 | 242 | | .Where(o => !string.IsNullOrEmpty(o.Article) && o.Quantity.HasValue && o.Quantity.Value > 0) |
| | 0 | 243 | | .ToList(); |
| | 0 | 244 | | var resolutions = await _nomenclatureService.ResolveManyAsync( |
| | 0 | 245 | | validOrders, NomenclatureMode.FindOrCreate, origin: new OneCNomenclatureOrigin(transactionType)); |
| | 0 | 246 | | var missingReasons = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 247 | | var nomenclatureNotes = new List<string>(); |
| | | 248 | | |
| | 0 | 249 | | var lines = new List<ODataExpenseLine>(); |
| | 0 | 250 | | var lineNum = 1; |
| | 0 | 251 | | for (var i = 0; i < validOrders.Count; i++) |
| | | 252 | | { |
| | 0 | 253 | | var order = validOrders[i]; |
| | 0 | 254 | | var resolution = resolutions[i]; |
| | 0 | 255 | | if (resolution.Created) |
| | 0 | 256 | | createdNomenclature.Add(resolution.DisplayName); |
| | 0 | 257 | | if (resolution.FoundByVariant && !string.IsNullOrWhiteSpace(resolution.Reason)) |
| | 0 | 258 | | nomenclatureNotes.Add($"{order.Article}: {resolution.Reason}"); |
| | | 259 | | |
| | | 260 | | // validOrders уже отфильтрован по Quantity.HasValue выше. |
| | 0 | 261 | | var qty = (decimal)(order.TrueQuantity ?? order.Quantity!.Value); |
| | 0 | 262 | | if (!resolution.RefKey.HasValue) |
| | | 263 | | { |
| | 0 | 264 | | missingItems.Add((order.Article ?? string.Empty, order.ProductName, qty)); |
| | 0 | 265 | | NomenclatureResolution.NoteReason(missingReasons, order.Article ?? string.Empty, resolution.Reason); |
| | 0 | 266 | | continue; |
| | | 267 | | } |
| | 0 | 268 | | var price = order.Price ?? 0m; |
| | 0 | 269 | | var amount = price * qty; |
| | 0 | 270 | | var vatAmount = vatRate.HasValue && vatRate.Value > 0 |
| | 0 | 271 | | ? Math.Round(amount * vatRate.Value / (100m + vatRate.Value), 2) |
| | 0 | 272 | | : 0m; |
| | | 273 | | |
| | 0 | 274 | | totalAmount += amount; |
| | 0 | 275 | | lines.Add(new ODataExpenseLine |
| | 0 | 276 | | { |
| | 0 | 277 | | LineNumber = lineNum++, |
| | 0 | 278 | | NomenclatureKey = resolution.RefKey.Value, |
| | 0 | 279 | | WarehouseKey = warehouseGuid, |
| | 0 | 280 | | Quantity = qty, |
| | 0 | 281 | | Price = price, |
| | 0 | 282 | | Amount = amount, |
| | 0 | 283 | | VatAmount = vatAmount, |
| | 0 | 284 | | TotalAmount = amount, |
| | 0 | 285 | | VatRateKey = effectiveVatGuid |
| | 0 | 286 | | }); |
| | | 287 | | } |
| | | 288 | | |
| | 0 | 289 | | if (!lines.Any() && !missingItems.Any()) |
| | 0 | 290 | | return new OneCResponse { Success = false, Message = $"Клиент '{client.Name}': нет позиций с валидными д |
| | | 291 | | |
| | 0 | 292 | | string fullComment = comment; |
| | 0 | 293 | | string? missingNomStr = null; |
| | 0 | 294 | | if (createAsDraft) |
| | | 295 | | { |
| | 0 | 296 | | fullComment = $"{comment}\n⚠ Черновик: {draftReason ?? "недостаточно остатка в 1С. Проведите после оприх |
| | | 297 | | } |
| | 0 | 298 | | if (missingItems.Any()) |
| | | 299 | | { |
| | 0 | 300 | | missingNomStr = string.Join(", ", missingItems.Select(i => FormatMissingItem(i) + NomenclatureResolution |
| | 0 | 301 | | var allMissing = !lines.Any(); |
| | 0 | 302 | | var missingNomenclatureDraftReason = allMissing |
| | 0 | 303 | | ? $"⚠ Черновик (все позиции без номенклатуры 1С) — {missingNomStr}" |
| | 0 | 304 | | : $"⚠ Черновик — не найдена номенклатура: {missingNomStr}"; |
| | 0 | 305 | | fullComment = $"{fullComment}\n{missingNomenclatureDraftReason}"; |
| | | 306 | | // Создание черновика при нехватке номенклатуры — штатный бизнес-сценарий: |
| | | 307 | | // документ сохраняется в 1С как черновик, а пользователю выводится всплывающее |
| | | 308 | | // уведомление (OneCResponse.Message / TempData["TransactionResult"]). |
| | | 309 | | // Уровень Information, чтобы не спамить в errors-*.log и не заводить issues. |
| | 0 | 310 | | await (_signals?.RecordAsync(OneCNomenclatureService.NomenclatureSignalSource, "failure", "missing", |
| | 0 | 311 | | $"Расходная: не найдена номенклатура: {missingNomStr}") ?? Task.CompletedTask); |
| | 0 | 312 | | _logger.LogInformation("[OData] Создаём черновик расходной накладной{Suffix}: не найдена номенклатура дл |
| | 0 | 313 | | !lines.Any() ? " (все позиции)" : "", missingNomStr); |
| | | 314 | | } |
| | | 315 | | |
| | 0 | 316 | | Guid? contractGuid = Guid.TryParse(contractLink, out var parsedGuid) ? parsedGuid : null; |
| | | 317 | | |
| | 0 | 318 | | var request = new ODataExpenseRequest |
| | 0 | 319 | | { |
| | 0 | 320 | | Date = FormatODataDate(documentDate), |
| | 0 | 321 | | OrganizationKey = orgGuid.Value, |
| | 0 | 322 | | CounterpartyKey = counterpartyGuid.Value, |
| | 0 | 323 | | ContractKey = contractGuid, |
| | 0 | 324 | | WarehouseKey = warehouseGuid, |
| | 0 | 325 | | DivisionKey = warehouseGuid, |
| | 0 | 326 | | TotalAmount = totalAmount, |
| | 0 | 327 | | Comment = fullComment, |
| | 0 | 328 | | Lines = lines |
| | 0 | 329 | | }; |
| | | 330 | | |
| | 0 | 331 | | var doc = await _odataClient.PostAsync<ODataExpenseRequest, ODataDocumentResponse>( |
| | 0 | 332 | | ExpenseEndpoint, request); |
| | | 333 | | |
| | 0 | 334 | | bool isPosted = false; |
| | 0 | 335 | | string? postingError = null; |
| | 0 | 336 | | string? errorDetail = null; |
| | 0 | 337 | | string docNumber = doc.Number ?? ""; |
| | | 338 | | |
| | 0 | 339 | | if (missingItems.Any()) |
| | | 340 | | { |
| | 0 | 341 | | var fetched = await _odataClient.GetByGuidAsync<ODataDocumentResponse>(ExpenseEndpoint, doc.RefKey); |
| | 0 | 342 | | if (fetched != null && !string.IsNullOrWhiteSpace(fetched.Number)) |
| | 0 | 343 | | docNumber = fetched.Number; |
| | 0 | 344 | | _logger.LogInformation("[OData] Расходная {Number} ({Guid}) — черновик, отсутствует номенклатура", docNu |
| | | 345 | | } |
| | 0 | 346 | | else if (!createAsDraft) |
| | | 347 | | { |
| | | 348 | | try |
| | | 349 | | { |
| | 0 | 350 | | await _odataClient.PostDocumentAsync(ExpenseEndpoint, doc.RefKey); |
| | 0 | 351 | | isPosted = true; |
| | 0 | 352 | | var fetched = await _odataClient.GetByGuidAsync<ODataDocumentResponse>(ExpenseEndpoint, doc.RefKey); |
| | 0 | 353 | | if (fetched != null && !string.IsNullOrWhiteSpace(fetched.Number)) |
| | 0 | 354 | | docNumber = fetched.Number; |
| | 0 | 355 | | } |
| | | 356 | | // Отказ 1С провести документ — ожидаемый бизнес-исход (обычно не хватает |
| | | 357 | | // остатка): документ остаётся черновиком, а готовая причина уходит пользователю |
| | | 358 | | // в алерт транзакции. Это не сбой интеграции, поэтому Information: Warning и Error |
| | | 359 | | // попадают в errors-*.log и заводят автоматический issue на штатную ситуацию. |
| | 0 | 360 | | catch (OneCDocumentPostingException postingEx) |
| | | 361 | | { |
| | 0 | 362 | | postingError = postingEx.Message; |
| | 0 | 363 | | errorDetail = OneCResponseErrors.Describe(postingEx); |
| | 0 | 364 | | _logger.LogInformation("[OData] 1С не провела расходной накладной {Guid}: {Reason}", doc.RefKey, pos |
| | 0 | 365 | | } |
| | 0 | 366 | | catch (Exception patchEx) |
| | | 367 | | { |
| | 0 | 368 | | postingError = OneCODataErrorFormatter.Format(patchEx.Message); |
| | 0 | 369 | | errorDetail = OneCResponseErrors.Describe(patchEx); |
| | 0 | 370 | | _logger.LogError(patchEx, "[OData] Ошибка проведения расходной накладной {Guid}", doc.RefKey); |
| | 0 | 371 | | } |
| | | 372 | | |
| | | 373 | | // 1С в ответе на /Post при нехватке остатка возвращает только общую фразу |
| | | 374 | | // «Не удалось провести "..."!» без детализации причины. Если в этом же документе |
| | | 375 | | // только что создана номенклатура — она гарантированно имеет нулевой остаток на |
| | | 376 | | // складе. Если ВСЕ строки документа — новая номенклатура, это точная причина |
| | | 377 | | // отказа; если только часть строк — это лишь вероятная причина (могло не хватить |
| | | 378 | | // остатка и по уже существующей позиции), формулировку смягчаем. |
| | 0 | 379 | | if (!isPosted && createdNomenclature.Any()) |
| | | 380 | | { |
| | 0 | 381 | | var namesJoined = string.Join(", ", createdNomenclature.Distinct(StringComparer.OrdinalIgnoreCase)); |
| | 0 | 382 | | var causePrefix = lines.Count == createdNomenclature.Count |
| | 0 | 383 | | ? "Не хватает остатка" |
| | 0 | 384 | | : "Вероятно, не хватает остатка"; |
| | 0 | 385 | | postingError = |
| | 0 | 386 | | $"{causePrefix} на складе «{warehouseName}»: только что созданная номенклатура ({namesJoined}) с |
| | | 387 | | } |
| | | 388 | | } |
| | | 389 | | |
| | | 390 | | string message; |
| | 0 | 391 | | if (missingItems.Any()) |
| | | 392 | | { |
| | 0 | 393 | | message = !lines.Any() |
| | 0 | 394 | | ? $"Расходная {docNumber} создана как черновик — все позиции без номенклатуры 1С: {missingNomStr}" |
| | 0 | 395 | | : $"Расходная {docNumber} создана как черновик — не найдена номенклатура: {missingNomStr}"; |
| | | 396 | | } |
| | | 397 | | else |
| | | 398 | | { |
| | 0 | 399 | | message = createAsDraft |
| | 0 | 400 | | ? $"Расходная накладная {docNumber} создана как черновик: {draftReason ?? "недостаточно остатка в 1С |
| | 0 | 401 | | : isPosted |
| | 0 | 402 | | ? $"Расходная накладная {docNumber} создана и проведена" |
| | 0 | 403 | | : $"Расходная накладная {docNumber} создана, проведение не выполнено: {postingError}"; |
| | | 404 | | } |
| | | 405 | | |
| | 0 | 406 | | return new OneCResponse |
| | 0 | 407 | | { |
| | 0 | 408 | | Success = true, |
| | 0 | 409 | | Message = message, |
| | 0 | 410 | | TransactionId = doc.RefKey.ToString(), |
| | 0 | 411 | | DocumentNumber = string.IsNullOrWhiteSpace(docNumber) ? null : docNumber, |
| | 0 | 412 | | IsPosted = isPosted, |
| | 0 | 413 | | IsDraft = createAsDraft, |
| | 0 | 414 | | MissingNomenclature = missingNomStr, |
| | 0 | 415 | | CreatedNomenclature = string.Join(", ", createdNomenclature.Distinct(StringComparer.OrdinalIgnoreCase)), |
| | 0 | 416 | | PostingError = postingError, |
| | 0 | 417 | | ErrorDetail = errorDetail, |
| | 0 | 418 | | NomenclatureNotes = nomenclatureNotes.Count > 0 ? nomenclatureNotes : null |
| | 0 | 419 | | }; |
| | | 420 | | } |
| | 0 | 421 | | catch (Exception ex) |
| | | 422 | | { |
| | 0 | 423 | | var msg = $"Ошибка при {operationLabel}: {ex.Message}"; |
| | 0 | 424 | | _logger.LogError(ex, "[OData] {Msg}", msg); |
| | 0 | 425 | | return new OneCResponse { Success = false, Message = msg, ErrorDetail = OneCResponseErrors.Describe(ex) }; |
| | | 426 | | } |
| | 0 | 427 | | } |
| | | 428 | | |
| | 0 | 429 | | public Task<bool> GetTestRequest(string clientId, string apiKey) => TestConnectionAsync(); |
| | | 430 | | |
| | | 431 | | private async Task<bool> TestConnectionAsync() |
| | | 432 | | { |
| | | 433 | | try |
| | | 434 | | { |
| | 0 | 435 | | await _referenceService.GetWarehousesAsync(); |
| | 0 | 436 | | return true; |
| | | 437 | | } |
| | 0 | 438 | | catch { return false; } |
| | 0 | 439 | | } |
| | | 440 | | |
| | | 441 | | // ─── Вспомогательные методы ──────────────────────────────────────────────── |
| | | 442 | | |
| | | 443 | | private static string? GetZasylValue(Order? order) |
| | 0 | 444 | | => order?.OrderExtensions? |
| | 0 | 445 | | .FirstOrDefault(x => string.Equals(x.Key, ZasylKey, StringComparison.OrdinalIgnoreCase)) |
| | 0 | 446 | | ?.Value?.Trim(); |
| | | 447 | | |
| | | 448 | | private static string? GetEffectiveWarehouseName(Order? order) |
| | | 449 | | { |
| | 0 | 450 | | var zasyl = GetZasylValue(order); |
| | 0 | 451 | | return !string.IsNullOrWhiteSpace(zasyl) ? zasyl : order?.ShipmentWarehouse?.Name?.Trim(); |
| | | 452 | | } |
| | | 453 | | |
| | | 454 | | private static string FormatODataDate(DateTime? documentDate) |
| | | 455 | | { |
| | 0 | 456 | | var tz = TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"); |
| | 0 | 457 | | var effectiveDate = documentDate.HasValue |
| | 0 | 458 | | ? NormalizeToRussianTime(documentDate.Value, tz) |
| | 0 | 459 | | : TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz); |
| | 0 | 460 | | return effectiveDate.ToString("yyyy-MM-ddTHH:mm:ss"); |
| | | 461 | | } |
| | | 462 | | |
| | | 463 | | private static DateTime NormalizeToRussianTime(DateTime value, TimeZoneInfo tz) |
| | 0 | 464 | | => value.Kind switch |
| | 0 | 465 | | { |
| | 0 | 466 | | DateTimeKind.Utc => TimeZoneInfo.ConvertTimeFromUtc(value, tz), |
| | 0 | 467 | | DateTimeKind.Local => TimeZoneInfo.ConvertTime(value, TimeZoneInfo.Local, tz), |
| | 0 | 468 | | _ => value |
| | 0 | 469 | | }; |
| | | 470 | | |
| | | 471 | | private static string BuildTransactionTitle(TransactionType transactionType, string? userName) |
| | | 472 | | { |
| | 0 | 473 | | var userPart = string.IsNullOrWhiteSpace(userName) ? "Система" : userName.Trim(); |
| | 0 | 474 | | return $"{transactionType.GetDisplayName()} (пользователь: {userPart})"; |
| | | 475 | | } |
| | | 476 | | |
| | | 477 | | private static string FormatMissingItem((string Article, string? ProductName, decimal Qty) item) |
| | 0 | 478 | | => string.IsNullOrWhiteSpace(item.ProductName) |
| | 0 | 479 | | ? $"{item.Article} ×{item.Qty}" |
| | 0 | 480 | | : $"{item.ProductName} [{item.Article}] ×{item.Qty}"; |
| | | 481 | | |
| | | 482 | | private static string BuildComment( |
| | | 483 | | TransactionType transactionType, |
| | | 484 | | string? userName, |
| | | 485 | | string clientName, |
| | | 486 | | IEnumerable<string?> shipmentNumbers, |
| | | 487 | | string? userComment = null) |
| | | 488 | | { |
| | 0 | 489 | | var title = BuildTransactionTitle(transactionType, userName); |
| | 0 | 490 | | var numbers = string.Join(", ", shipmentNumbers.Where(n => !string.IsNullOrEmpty(n))); |
| | 0 | 491 | | var auto = $"{title} для клиента {clientName}. Заказы: {numbers}"; |
| | 0 | 492 | | return !string.IsNullOrWhiteSpace(userComment) |
| | 0 | 493 | | ? $"{title}. {userComment.Trim()}" |
| | 0 | 494 | | : auto; |
| | | 495 | | } |
| | | 496 | | } |