| | | 1 | | using System.Text.RegularExpressions; |
| | | 2 | | using System.Security.Cryptography; |
| | | 3 | | using System.Globalization; |
| | | 4 | | using Microsoft.AspNetCore.Authorization; |
| | | 5 | | using Microsoft.AspNetCore.Mvc; |
| | | 6 | | using OzonOrdersWeb.Filters; |
| | | 7 | | using Microsoft.EntityFrameworkCore; |
| | | 8 | | using Microsoft.Extensions.Caching.Memory; |
| | | 9 | | using OzonDomains.Models; |
| | | 10 | | using OzonDomains.Models.SupplierStatuses; |
| | | 11 | | using OzonOrdersWeb.Authorization; |
| | | 12 | | using OzonOrdersWeb.Areas.Studio2.ViewModels.SupplierCart; |
| | | 13 | | using OzonRepositories.Context; |
| | | 14 | | using Services.ApiServices.AvdApi; |
| | | 15 | | using Services.ApiServices.JapartsApi; |
| | | 16 | | using Services.ApiServices.BergApi; |
| | | 17 | | using Services.ApiServices.ArmtekApi; |
| | | 18 | | using Services.ApiServices.ArmtekApi.Models.Response; |
| | | 19 | | using Services.ApiServices.FavoritPartsApi; |
| | | 20 | | using Services.ApiServices.MlAutoApi; |
| | | 21 | | using Services.ApiServices.MoskvorechieApi; |
| | | 22 | | using Services.ApiServices.MotexApi; |
| | | 23 | | using Services.ApiServices.ProfitLigaApi; |
| | | 24 | | using Services.ApiServices.NikeiApi; |
| | | 25 | | using Services.ApiServices.ShateMApi; |
| | | 26 | | using MikadoCartService = OzonOrdersWeb.TransactionServices.MikadoCartService; |
| | | 27 | | using ZZapCartService = OzonOrdersWeb.TransactionServices.ZZapCartService; |
| | | 28 | | using Services.ApiServices.ZZapApi; |
| | | 29 | | using Newtonsoft.Json.Linq; |
| | | 30 | | using OzonOrdersWeb.TransactionServices; |
| | | 31 | | using OzonOrdersWeb.WebServices.SupplierCart; |
| | | 32 | | using Services.SupplierStatusServices; |
| | | 33 | | |
| | | 34 | | namespace OzonOrdersWeb.Areas.Studio2.Controllers; |
| | | 35 | | |
| | | 36 | | /// <summary>Общая корзина API-поставщиков и ручное оформление заказов.</summary> |
| | | 37 | | [Area("Studio2")] |
| | | 38 | | [Authorize(Roles = "User,Admin")] |
| | | 39 | | public sealed class SupplierCartController : Controller |
| | | 40 | | { |
| | | 41 | | // Новый формат комментария — "Studio2 {номер отправления} (ID {orderId})"; старый (позиции, добавленные |
| | | 42 | | // до этого формата) — "Studio2 заказ {orderId}". Пробуем новый, затем откатываемся на старый. |
| | 1 | 43 | | private static readonly Regex StudioOrderIdPattern = new(@"^Studio2 .+ \(ID (\d+)\)$", RegexOptions.Compiled); |
| | 1 | 44 | | private static readonly Regex LegacyStudioOrderCommentPattern = new(@"Studio2 заказ (\d+)", RegexOptions.Compiled); |
| | | 45 | | |
| | | 46 | | /// <summary>Период заказов ML-Auto по умолчанию: API отдаёт весь список без фильтра по датам.</summary> |
| | | 47 | | private const int MlAutoDefaultPeriodDays = 30; |
| | | 48 | | /// <summary>Состав каждого заказа — отдельный запрос, поэтому число разворачиваемых заказов ограничено.</summary> |
| | | 49 | | private const int MlAutoMaxDetailedOrders = 60; |
| | | 50 | | private const int MlAutoDetailsConcurrency = 4; |
| | | 51 | | |
| | | 52 | | /// <summary>Глубина истории заказов Japarts: одна и та же для вкладки «Заказы» и её счётчика.</summary> |
| | | 53 | | private const int JapartsOrdersIntervalDays = 60; |
| | | 54 | | |
| | | 55 | | private readonly AvdDataManager _avd; |
| | | 56 | | private readonly JapartsDataManager _japarts; |
| | | 57 | | private readonly BergDataManager _berg; |
| | | 58 | | private readonly BergCartService _bergCartService; |
| | | 59 | | private readonly ApplicationDbContext _context; |
| | | 60 | | private readonly IHttpClientFactory _httpClientFactory; |
| | | 61 | | private readonly IMemoryCache _cache; |
| | | 62 | | private readonly ArmtekCartService _armtekCart; |
| | | 63 | | private readonly ArmtekDataManager _armtek; |
| | | 64 | | private readonly ArmtekByDataManager _armtekBy; |
| | | 65 | | private readonly MlAutoCartService _mlAutoCart; |
| | | 66 | | private readonly MlAutoDataManager _mlAuto; |
| | | 67 | | private readonly MlAutoRuCartService _mlAutoRuCart; |
| | | 68 | | private readonly MlAutoRuDataManager _mlAutoRu; |
| | | 69 | | private readonly ForumAutoCartService _forumAutoCart; |
| | | 70 | | private readonly Services.ApiServices.ForumAutoApi.ForumAutoDataManager _forumAuto; |
| | | 71 | | private readonly Services.ApiServices.ForumAutoApi.ForumAutoInterpartsDataManager _forumAutoInterparts; |
| | | 72 | | private readonly Services.ApiServices.ForumAutoApi.ForumAutoPiterDataManager _forumAutoPiter; |
| | | 73 | | private readonly Services.ApiServices.ForumAutoApi.ForumAutoRostovDataManager _forumAutoRostov; |
| | | 74 | | private readonly Services.ApiServices.ForumAutoApi.ForumAutoIstraDataManager _forumAutoIstra; |
| | | 75 | | private readonly FavoritPartsCartService _favoritPartsCart; |
| | | 76 | | private readonly FavoritPartsIstraCartService _favoritPartsIstraCart; |
| | | 77 | | private readonly FavoritPartsRostovCartService _favoritPartsRostovCart; |
| | | 78 | | private readonly MoskvorechieIstraCartService _moskvorechieIstraCart; |
| | | 79 | | private readonly MotexCartService _motexCart; |
| | | 80 | | private readonly ProfitLigaCartService _profitLigaCart; |
| | | 81 | | private readonly NikeiCartService _nikeiCart; |
| | | 82 | | private readonly ShateMCartService _shateMCart; |
| | | 83 | | private readonly MikadoCartService _mikadoCart; |
| | | 84 | | private readonly ZZapCartService _zzapCart; |
| | | 85 | | private readonly ShateMDataManager _shateM; |
| | | 86 | | private readonly MotexDataManager _motex; |
| | | 87 | | private readonly IOrderSupplierStateService _supplierStateService; |
| | | 88 | | private readonly ILogger<SupplierCartController> _logger; |
| | 0 | 89 | | private sealed record ArmtekCheckoutPreparation(string UserName, string Provider, long[]? CartItemIds); |
| | 0 | 90 | | private sealed record SupplierCartSummaryRow(string Provider, int CartCount, int OrderCount); |
| | | 91 | | |
| | 0 | 92 | | public SupplierCartController( |
| | 0 | 93 | | AvdDataManager avd, |
| | 0 | 94 | | JapartsDataManager japarts, |
| | 0 | 95 | | BergDataManager berg, |
| | 0 | 96 | | BergCartService bergCartService, |
| | 0 | 97 | | ApplicationDbContext context, |
| | 0 | 98 | | IHttpClientFactory httpClientFactory, |
| | 0 | 99 | | IMemoryCache cache, |
| | 0 | 100 | | ArmtekCartService armtekCart, |
| | 0 | 101 | | ArmtekDataManager armtek, |
| | 0 | 102 | | ArmtekByDataManager armtekBy, MlAutoCartService mlAutoCart, MlAutoDataManager mlAuto, MlAutoRuCartService mlAuto |
| | 0 | 103 | | ForumAutoCartService forumAutoCart, Services.ApiServices.ForumAutoApi.ForumAutoDataManager forumAuto, |
| | 0 | 104 | | Services.ApiServices.ForumAutoApi.ForumAutoInterpartsDataManager forumAutoInterparts, |
| | 0 | 105 | | Services.ApiServices.ForumAutoApi.ForumAutoPiterDataManager forumAutoPiter, |
| | 0 | 106 | | Services.ApiServices.ForumAutoApi.ForumAutoRostovDataManager forumAutoRostov, |
| | 0 | 107 | | Services.ApiServices.ForumAutoApi.ForumAutoIstraDataManager forumAutoIstra, |
| | 0 | 108 | | FavoritPartsCartService favoritPartsCart, FavoritPartsIstraCartService favoritPartsIstraCart, FavoritPartsRostov |
| | 0 | 109 | | IOrderSupplierStateService supplierStateService, |
| | 0 | 110 | | ILogger<SupplierCartController> logger) |
| | | 111 | | { |
| | 0 | 112 | | _avd = avd; |
| | 0 | 113 | | _japarts = japarts; |
| | 0 | 114 | | _berg = berg; |
| | 0 | 115 | | _bergCartService = bergCartService; |
| | 0 | 116 | | _context = context; |
| | 0 | 117 | | _httpClientFactory = httpClientFactory; |
| | 0 | 118 | | _cache = cache; |
| | 0 | 119 | | _armtekCart = armtekCart; |
| | 0 | 120 | | _armtek = armtek; |
| | 0 | 121 | | _armtekBy = armtekBy; |
| | 0 | 122 | | _mlAutoCart = mlAutoCart; |
| | 0 | 123 | | _mlAuto = mlAuto; |
| | 0 | 124 | | _mlAutoRuCart = mlAutoRuCart; |
| | 0 | 125 | | _mlAutoRu = mlAutoRu; |
| | 0 | 126 | | _forumAutoCart = forumAutoCart; |
| | 0 | 127 | | _forumAuto = forumAuto; |
| | 0 | 128 | | _forumAutoInterparts = forumAutoInterparts; |
| | 0 | 129 | | _forumAutoPiter = forumAutoPiter; |
| | 0 | 130 | | _forumAutoRostov = forumAutoRostov; |
| | 0 | 131 | | _forumAutoIstra = forumAutoIstra; |
| | 0 | 132 | | _favoritPartsCart = favoritPartsCart; |
| | 0 | 133 | | _favoritPartsIstraCart = favoritPartsIstraCart; |
| | 0 | 134 | | _favoritPartsRostovCart = favoritPartsRostovCart; |
| | 0 | 135 | | _moskvorechieIstraCart = moskvorechieIstraCart; |
| | 0 | 136 | | _motexCart = motexCart; |
| | 0 | 137 | | _profitLigaCart = profitLigaCart; |
| | 0 | 138 | | _nikeiCart = nikeiCart; |
| | 0 | 139 | | _shateMCart = shateMCart; |
| | 0 | 140 | | _mikadoCart = mikadoCart; |
| | 0 | 141 | | _zzapCart = zzapCart; |
| | 0 | 142 | | _shateM = shateM; |
| | 0 | 143 | | _motex = motex; |
| | 0 | 144 | | _supplierStateService = supplierStateService; |
| | 0 | 145 | | _logger = logger; |
| | 0 | 146 | | } |
| | | 147 | | |
| | 0 | 148 | | private static bool IsForumAutoProvider(string provider) => provider is ApiSupplierSettings.ForumAutoProviderKey or |
| | 0 | 149 | | private Services.ApiServices.ForumAutoApi.ForumAutoDataManager ResolveForumAutoClient(string provider) => provider s |
| | 0 | 150 | | { |
| | 0 | 151 | | ApiSupplierSettings.ForumAutoInterpartsProviderKey => _forumAutoInterparts, |
| | 0 | 152 | | ApiSupplierSettings.ForumAutoPiterProviderKey => _forumAutoPiter, |
| | 0 | 153 | | ApiSupplierSettings.ForumAutoRostovProviderKey => _forumAutoRostov, |
| | 0 | 154 | | ApiSupplierSettings.ForumAutoIstraProviderKey => _forumAutoIstra, |
| | 0 | 155 | | _ => _forumAuto |
| | 0 | 156 | | }; |
| | | 157 | | |
| | | 158 | | [HttpGet] |
| | | 159 | | public IActionResult Index() |
| | | 160 | | { |
| | | 161 | | // Страница — только быстрый shell. Данные вкладки подгружаются запросом Grid |
| | | 162 | | // после выбора поставщика, чтобы открытие /SupplierCart не ждало все API поставщиков. |
| | 0 | 163 | | return View(); |
| | | 164 | | } |
| | | 165 | | |
| | | 166 | | /// <summary> |
| | | 167 | | /// Данные вкладки поставщика для гридов AG Grid «Корзина» и «Заказы» (строки собирает <see cref="SupplierCartGridRo |
| | | 168 | | /// dateBegin/dateEnd/number — параметры API поставщика (период заказов), остальное фильтрует грид. |
| | | 169 | | /// </summary> |
| | | 170 | | [HttpGet] |
| | | 171 | | public async Task<IActionResult> Grid( |
| | | 172 | | string? provider, |
| | | 173 | | string? dateBegin, |
| | | 174 | | string? dateEnd, |
| | | 175 | | string? number = null, |
| | | 176 | | CancellationToken ct = default) |
| | | 177 | | { |
| | 0 | 178 | | var route = SupplierCartGridRows.NormalizeRoute(provider); |
| | 0 | 179 | | var section = await BuildProviderSectionAsync(route, dateBegin, dateEnd, number, ct); |
| | 0 | 180 | | if (section is null) return BadRequest(new { error = "Неизвестный поставщик корзины." }); |
| | 0 | 181 | | return Ok(SupplierCartGridRows.Build(route, section, |
| | 0 | 182 | | url => IsAllowedProductImageUrl(url) |
| | 0 | 183 | | ? Url.Action(nameof(ImageProxy), "SupplierCart", new { area = "Studio2", url }) ?? string.Empty |
| | 0 | 184 | | : string.Empty)); |
| | 0 | 185 | | } |
| | | 186 | | |
| | | 187 | | /// <summary>Лёгкая сводка для счётчиков вкладок; не строит их содержимое.</summary> |
| | | 188 | | [HttpGet] |
| | | 189 | | public async Task<IActionResult> Summary(CancellationToken ct = default) |
| | | 190 | | { |
| | 0 | 191 | | var summary = await _cache.GetOrCreateAsync("supplier-cart:summary", async entry => |
| | 0 | 192 | | { |
| | 0 | 193 | | entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30); |
| | 0 | 194 | | return await BuildSummaryAsync(ct); |
| | 0 | 195 | | }); |
| | 0 | 196 | | return Ok(summary); |
| | 0 | 197 | | } |
| | | 198 | | |
| | | 199 | | private async Task<IReadOnlyList<SupplierCartSummaryRow>> BuildSummaryAsync(CancellationToken ct) |
| | | 200 | | { |
| | 0 | 201 | | var avdCartCount = 0; |
| | 0 | 202 | | var avdOrderCount = 0; |
| | | 203 | | try |
| | | 204 | | { |
| | 0 | 205 | | avdCartCount = (await _avd.GetBasketItemsAsync(ct)).Count; |
| | 0 | 206 | | avdOrderCount = (await _avd.GetOrdersParsedAsync(null, null, 0, null, ct)).Count; |
| | 0 | 207 | | } |
| | 0 | 208 | | catch (AvdApiException) |
| | | 209 | | { |
| | | 210 | | // При временной недоступности АВД сохраняем остальные, локальные счётчики. |
| | 0 | 211 | | } |
| | | 212 | | |
| | 0 | 213 | | var japartsCartCount = 0; |
| | 0 | 214 | | var japartsOrderCount = 0; |
| | | 215 | | try |
| | | 216 | | { |
| | 0 | 217 | | japartsCartCount = (await _japarts.GetBasketAsync(ct)).Count; |
| | 0 | 218 | | japartsOrderCount = (await _japarts.GetOrdersAsync(JapartsOrdersIntervalDays, ct)).Count; |
| | 0 | 219 | | } |
| | 0 | 220 | | catch (Exception exception) when (exception is JapartsApiException or HttpRequestException) |
| | | 221 | | { |
| | | 222 | | // Как и у АВД: недоступность API поставщика не должна гасить остальные счётчики. |
| | 0 | 223 | | } |
| | | 224 | | |
| | | 225 | | // Вкладка Berg показывает активные заказы поставщика, а не строки локальной таблицы: |
| | | 226 | | // счётчик должен считать то же самое, иначе в шапке всегда ноль. |
| | 0 | 227 | | var bergOrderCount = 0; |
| | | 228 | | try |
| | | 229 | | { |
| | 0 | 230 | | bergOrderCount = BergDataManager.ParseOrders(await _berg.GetActiveOrderStatesAsync(ct)).Count; |
| | 0 | 231 | | } |
| | 0 | 232 | | catch (Exception exception) when (exception is BergApiException or HttpRequestException) |
| | | 233 | | { |
| | | 234 | | // Как и у АВД: недоступность API поставщика не должна гасить остальные счётчики. |
| | 0 | 235 | | } |
| | | 236 | | |
| | 0 | 237 | | var rows = new List<SupplierCartSummaryRow> |
| | 0 | 238 | | { |
| | 0 | 239 | | new("avd", avdCartCount, avdOrderCount), |
| | 0 | 240 | | new("japarts", japartsCartCount, japartsOrderCount), |
| | 0 | 241 | | new("berg", await _bergCartService.CountActiveAsync(ct), bergOrderCount), |
| | 0 | 242 | | new("armtek", await _context.ArmtekCartItems.CountAsync(x => x.ProviderKey == "Armtek" && ArmtekCartItemStat |
| | 0 | 243 | | new("armtekby", await _context.ArmtekCartItems.CountAsync(x => x.ProviderKey == "ArmtekBy" && ArmtekCartItem |
| | 0 | 244 | | new("mlauto", await _context.MlAutoCartItems.CountAsync(x => MlAutoCartItemStatus.Active.Contains(x.Status), |
| | 0 | 245 | | new("mlautoru", await _context.MlAutoRuCartItems.CountAsync(x => MlAutoRuCartItemStatus.Active.Contains(x.St |
| | 0 | 246 | | new("forumauto", await CountForumAutoCartAsync(ApiSupplierSettings.ForumAutoProviderKey, ct), await CountFor |
| | 0 | 247 | | new("forumautointerparts", await CountForumAutoCartAsync(ApiSupplierSettings.ForumAutoInterpartsProviderKey, |
| | 0 | 248 | | new("forumautopiter", await CountForumAutoCartAsync(ApiSupplierSettings.ForumAutoPiterProviderKey, ct), awai |
| | 0 | 249 | | new("forumautorostov", await CountForumAutoCartAsync(ApiSupplierSettings.ForumAutoRostovProviderKey, ct), aw |
| | 0 | 250 | | new("forumautoistra", await CountForumAutoCartAsync(ApiSupplierSettings.ForumAutoIstraProviderKey, ct), awai |
| | 0 | 251 | | new("favoritparts", await CountFavoritPartsCartAsync(ApiSupplierSettings.FavoritPartsProviderKey, ct), await |
| | 0 | 252 | | new("favoritpartsistra", await CountFavoritPartsCartAsync(ApiSupplierSettings.FavoritPartsIstraProviderKey, |
| | 0 | 253 | | new("favoritpartsrostov", await CountFavoritPartsCartAsync(ApiSupplierSettings.FavoritPartsRostovProviderKey |
| | 0 | 254 | | new("moskvorechieistra", await _context.MoskvorechieIstraCartItems.CountAsync(x => x.ActiveStudioOrderId != |
| | 0 | 255 | | new("motex", await _motexCart.CountActiveAsync(ct), await GetMotexOrderCountAsync(ct)), |
| | 0 | 256 | | new("profitliga", await _profitLigaCart.CountActiveAsync(ct), await _context.ProfitLigaOrders.CountAsync(ct) |
| | 0 | 257 | | new("nikei", await _nikeiCart.CountActiveAsync(ct), await _context.NikeiCartItems.CountAsync(x => x.Status = |
| | 0 | 258 | | new("mikadomskhod20", await _mikadoCart.CountActiveAsync(ct), 0), |
| | 0 | 259 | | new("zzapmoscow", await _zzapCart.CountActiveAsync(ct), await _zzapCart.CountOrderedAsync(ct)), |
| | 0 | 260 | | new("shatem", await _shateMCart.CountActiveAsync(ct), await _context.ShateMOrders.CountAsync(ct)) |
| | 0 | 261 | | }; |
| | 0 | 262 | | return rows; |
| | 0 | 263 | | } |
| | | 264 | | |
| | | 265 | | private Task<int> CountForumAutoCartAsync(string providerKey, CancellationToken ct) => |
| | 0 | 266 | | _context.ForumAutoCartItems.CountAsync(x => x.ProviderKey == providerKey && ForumAutoCartItemStatus.Active.Conta |
| | | 267 | | |
| | | 268 | | private Task<int> CountForumAutoOrdersAsync(string providerKey, CancellationToken ct) => |
| | 0 | 269 | | _context.ForumAutoOrders.CountAsync(x => x.ProviderKey == providerKey, ct); |
| | | 270 | | |
| | | 271 | | private Task<int> CountFavoritPartsCartAsync(string providerKey, CancellationToken ct) => |
| | 0 | 272 | | _context.FavoritPartsCartItems.CountAsync(x => x.ProviderKey == providerKey && FavoritPartsCartItemStatus.Active |
| | | 273 | | |
| | | 274 | | private Task<int> CountFavoritPartsOrdersAsync(string providerKey, CancellationToken ct) => |
| | 0 | 275 | | _context.FavoritPartsOrders.CountAsync(x => x.ProviderKey == providerKey, ct); |
| | | 276 | | |
| | | 277 | | private async Task<SupplierCartSection?> BuildProviderSectionAsync( |
| | | 278 | | string? provider, |
| | | 279 | | string? dateBegin, |
| | | 280 | | string? dateEnd, |
| | | 281 | | string? number, |
| | | 282 | | CancellationToken ct) |
| | | 283 | | { |
| | 0 | 284 | | var route = provider?.Trim().ToLowerInvariant() ?? "avd"; |
| | 0 | 285 | | var createdFrom = DateOnly.TryParse(dateBegin, out var parsedFrom) ? parsedFrom : DateOnly.FromDateTime(DateTime |
| | 0 | 286 | | var createdTo = DateOnly.TryParse(dateEnd, out var parsedTo) ? parsedTo : createdFrom; |
| | | 287 | | |
| | 0 | 288 | | if (route == "avd") |
| | | 289 | | { |
| | | 290 | | SupplierCartSection avdSection; |
| | | 291 | | try |
| | | 292 | | { |
| | 0 | 293 | | var avdItems = await _avd.GetBasketItemsAsync(ct); |
| | 0 | 294 | | avdSection = await BuildAvdSectionAsync(avdItems, ct); |
| | 0 | 295 | | } |
| | 0 | 296 | | catch (AvdApiException ex) |
| | | 297 | | { |
| | 0 | 298 | | avdSection = SupplierCartSection.Failed(ApiSupplierSettings.AvdProviderKey, "АВД", "/images/avd-logo.svg |
| | 0 | 299 | | } |
| | | 300 | | |
| | | 301 | | try |
| | | 302 | | { |
| | | 303 | | // Статус позиции фильтрует грид заказов (set-фильтр), API получает только период и номер. |
| | 0 | 304 | | var orders = await _avd.GetOrdersParsedAsync(dateBegin, dateEnd, 0, number, ct); |
| | 0 | 305 | | return avdSection with { Orders = orders }; |
| | | 306 | | } |
| | 0 | 307 | | catch (AvdApiException ex) |
| | | 308 | | { |
| | 0 | 309 | | return avdSection with { OrdersError = ex.Message }; |
| | | 310 | | } |
| | | 311 | | } |
| | | 312 | | |
| | 0 | 313 | | if (route == "japarts") |
| | | 314 | | { |
| | 0 | 315 | | try { return await BuildJapartsSectionAsync(ct); } |
| | 0 | 316 | | catch (JapartsApiException ex) { return SupplierCartSection.Failed(ApiSupplierSettings.JapartsProviderKey, " |
| | | 317 | | } |
| | | 318 | | |
| | 0 | 319 | | if (route == "berg") |
| | | 320 | | { |
| | 0 | 321 | | try { return await BuildBergSectionAsync(ct); } |
| | 0 | 322 | | catch (BergApiException ex) { return SupplierCartSection.Failed(ApiSupplierSettings.BergProviderKey, "Берг", |
| | | 323 | | } |
| | | 324 | | |
| | 0 | 325 | | return route switch |
| | 0 | 326 | | { |
| | 0 | 327 | | "armtek" => await BuildArmtekSectionAsync("Armtek", "Армтек RU", "/images/armtek-logo.svg", createdFrom, cre |
| | 0 | 328 | | "armtekby" => await BuildArmtekSectionAsync("ArmtekBy", "Армтек BY", "/images/armtek-logo.svg", createdFrom, |
| | 0 | 329 | | "mlauto" => await BuildMlAutoSectionAsync( |
| | 0 | 330 | | DateOnly.TryParse(dateBegin, out var mlFrom) ? mlFrom : DateOnly.FromDateTime(DateTime.Today).AddDays(-M |
| | 0 | 331 | | DateOnly.TryParse(dateEnd, out var mlTo) ? mlTo : DateOnly.FromDateTime(DateTime.Today), ct), |
| | 0 | 332 | | "mlautoru" => await BuildMlAutoRuSectionAsync(ct), |
| | 0 | 333 | | "forumauto" => await BuildForumAutoSectionAsync(ApiSupplierSettings.ForumAutoProviderKey, "Форум-Авто (Союз) |
| | 0 | 334 | | "forumautointerparts" => await BuildForumAutoSectionAsync(ApiSupplierSettings.ForumAutoInterpartsProviderKey |
| | 0 | 335 | | "forumautopiter" => await BuildForumAutoSectionAsync(ApiSupplierSettings.ForumAutoPiterProviderKey, "Форум-А |
| | 0 | 336 | | "forumautorostov" => await BuildForumAutoSectionAsync(ApiSupplierSettings.ForumAutoRostovProviderKey, "Форум |
| | 0 | 337 | | "forumautoistra" => await BuildForumAutoSectionAsync(ApiSupplierSettings.ForumAutoIstraProviderKey, "Форум-А |
| | 0 | 338 | | "favoritparts" => await BuildFavoritPartsSectionAsync(ct), |
| | 0 | 339 | | "favoritpartsistra" => await BuildFavoritPartsSectionAsync(_favoritPartsIstraCart, ApiSupplierSettings.Favor |
| | 0 | 340 | | "favoritpartsrostov" => await BuildFavoritPartsSectionAsync(_favoritPartsRostovCart, ApiSupplierSettings.Fav |
| | 0 | 341 | | "moskvorechieistra" => await BuildMoskvorechieIstraSectionAsync(ct), |
| | 0 | 342 | | "motex" => await BuildMotexSectionAsync( |
| | 0 | 343 | | DateOnly.TryParse(dateBegin, out var motexFrom) ? motexFrom : DateOnly.FromDateTime(DateTime.Today).AddD |
| | 0 | 344 | | DateOnly.TryParse(dateEnd, out var motexTo) ? motexTo : DateOnly.FromDateTime(DateTime.Today), ct), |
| | 0 | 345 | | "shatem" => await BuildShateMSectionAsync(ct), |
| | 0 | 346 | | "profitliga" => await BuildProfitLigaSectionAsync(ct), |
| | 0 | 347 | | "nikei" => await BuildNikeiSectionAsync(ct), |
| | 0 | 348 | | "mikadomskhod20" => await BuildMikadoSectionAsync(ct), |
| | 0 | 349 | | "zzapmoscow" => await BuildZZapMoscowSectionAsync(ct), |
| | 0 | 350 | | _ => null |
| | 0 | 351 | | }; |
| | 0 | 352 | | } |
| | | 353 | | |
| | | 354 | | /// <summary>Количество позиций в корзинах поставщиков для индикатора в боковом меню.</summary> |
| | | 355 | | [HttpGet, ServiceEndpoint] |
| | | 356 | | public async Task<IActionResult> Count(CancellationToken ct) |
| | | 357 | | { |
| | | 358 | | try |
| | | 359 | | { |
| | 2 | 360 | | var count = await _cache.GetOrCreateAsync(SupplierCartCacheKeys.AvdBasketCount, async entry => |
| | 2 | 361 | | { |
| | 0 | 362 | | entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30); |
| | 2 | 363 | | |
| | 0 | 364 | | var avdCount = 0; |
| | 2 | 365 | | try |
| | 2 | 366 | | { |
| | 0 | 367 | | avdCount = (await _avd.GetBasketItemsAsync(ct)).Count; |
| | 0 | 368 | | } |
| | 0 | 369 | | catch (Exception) |
| | 2 | 370 | | { |
| | 2 | 371 | | // Сбой или недоступность АВД не должны ронять общий счётчик в боковом меню |
| | 0 | 372 | | } |
| | 2 | 373 | | |
| | 0 | 374 | | var favoritPartsCount = 0; |
| | 0 | 375 | | try { favoritPartsCount = await _favoritPartsCart.CountApiCartAsync(ct); } |
| | 0 | 376 | | catch (Exception) { /* один недоступный поставщик не должен ронять общий счётчик */ } |
| | 2 | 377 | | |
| | 0 | 378 | | var favoritPartsIstraCount = 0; |
| | 0 | 379 | | try { favoritPartsIstraCount = await _favoritPartsIstraCart.CountApiCartAsync(ct); } |
| | 0 | 380 | | catch (Exception) { /* один недоступный поставщик не должен ронять общий счётчик */ } |
| | 2 | 381 | | |
| | 0 | 382 | | var favoritPartsRostovCount = 0; |
| | 0 | 383 | | try { favoritPartsRostovCount = await _favoritPartsRostovCart.CountApiCartAsync(ct); } |
| | 0 | 384 | | catch (Exception) { /* один недоступный поставщик не должен ронять общий счётчик */ } |
| | 2 | 385 | | |
| | 0 | 386 | | return avdCount |
| | 0 | 387 | | + await _armtekCart.CountActiveAsync(ct) |
| | 0 | 388 | | + await _mlAutoCart.CountActiveAsync(ct) |
| | 0 | 389 | | + await _mlAutoRuCart.CountActiveAsync(ct) |
| | 0 | 390 | | + await _forumAutoCart.CountActiveAsync(ct) |
| | 0 | 391 | | + favoritPartsCount |
| | 0 | 392 | | + favoritPartsIstraCount |
| | 0 | 393 | | + favoritPartsRostovCount; |
| | 2 | 394 | | }); |
| | 0 | 395 | | return Ok(new { count }); |
| | | 396 | | } |
| | 1 | 397 | | catch (OperationCanceledException) when (ct.IsCancellationRequested) |
| | | 398 | | { |
| | 1 | 399 | | throw; |
| | | 400 | | } |
| | 1 | 401 | | catch (Exception ex) |
| | | 402 | | { |
| | 1 | 403 | | _logger.LogWarning(ex, "Не удалось получить общий счётчик корзины поставщиков для бокового меню"); |
| | 1 | 404 | | return Ok(new { count = 0 }); |
| | | 405 | | } |
| | 1 | 406 | | } |
| | | 407 | | |
| | | 408 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 409 | | public async Task<IActionResult> TestMlAutoOrder(long[]? cartItemIds, CancellationToken ct) |
| | | 410 | | { |
| | 0 | 411 | | var result = await _mlAutoCart.TestAsync(cartItemIds, User.Identity?.Name ?? "unknown", ct); |
| | 0 | 412 | | await RefreshLocalCartStatusesAsync(ApiSupplierSettings.MlAutoProviderKey, cartItemIds, ct); |
| | 0 | 413 | | return Ok(result); |
| | 0 | 414 | | } |
| | | 415 | | |
| | | 416 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 417 | | [SupplierOrderGuard(ApiSupplierSettings.MlAutoProviderKey)] |
| | | 418 | | public async Task<IActionResult> CreateMlAutoOrder(long[]? cartItemIds, bool cashless, CancellationToken ct) |
| | | 419 | | { |
| | 0 | 420 | | var result = await _mlAutoCart.CheckoutAsync(cartItemIds, cashless, User.Identity?.Name ?? "unknown", ct); |
| | 0 | 421 | | await RefreshLocalCartStatusesAsync(ApiSupplierSettings.MlAutoProviderKey, cartItemIds, ct); |
| | 0 | 422 | | return Ok(result); |
| | 0 | 423 | | } |
| | | 424 | | |
| | | 425 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 426 | | public async Task<IActionResult> RemoveMlAutoCartItems(long[]? cartItemIds, CancellationToken ct) |
| | 0 | 427 | | => Ok(new { success = true, removed = await _mlAutoCart.RemoveAsync(cartItemIds, ct) }); |
| | | 428 | | |
| | | 429 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 430 | | public async Task<IActionResult> UpdateMlAutoDeliveryAddress(long cartItemId, string? deliveryAddressId, string? del |
| | | 431 | | { |
| | 0 | 432 | | if (string.IsNullOrWhiteSpace(deliveryAddressId)) return BadRequest(new { error = "Выберите адрес ML-Auto." }); |
| | 0 | 433 | | if (!await _mlAutoCart.UpdateAddressAsync(cartItemId, deliveryAddressId, deliveryAddressName, ct)) |
| | 0 | 434 | | return BadRequest(new { error = "Позиция не найдена или оформляется." }); |
| | | 435 | | |
| | 0 | 436 | | await RefreshLocalCartStatusesAsync(ApiSupplierSettings.MlAutoProviderKey, [cartItemId], ct); |
| | 0 | 437 | | return Ok(new { success = true }); |
| | 0 | 438 | | } |
| | | 439 | | |
| | | 440 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 441 | | public async Task<IActionResult> RequoteMlAutoCartItem(long cartItemId, CancellationToken ct) |
| | | 442 | | { |
| | | 443 | | try |
| | | 444 | | { |
| | 0 | 445 | | var quote = await _mlAutoCart.RequoteAsync(cartItemId, ct); |
| | 0 | 446 | | return Ok(new |
| | 0 | 447 | | { |
| | 0 | 448 | | success = true, |
| | 0 | 449 | | quote.OriginalWarehouse, |
| | 0 | 450 | | quote.DeliveryAddress, |
| | | 451 | | offers = quote.Offers.Select(x => new |
| | | 452 | | { |
| | | 453 | | x.StorageCode, x.WarehouseName, x.Price, x.AvailableQuantity, |
| | | 454 | | deliveryAt = x.DeliveryAtUtc?.ToString("yyyy-MM-ddTHH:mm:ss") |
| | | 455 | | }) |
| | 0 | 456 | | }); |
| | | 457 | | } |
| | 0 | 458 | | catch (Exception ex) when (ex is MlAutoApiException or HttpRequestException or InvalidOperationException) |
| | | 459 | | { |
| | 0 | 460 | | return BadRequest(new { error = ex.Message }); |
| | | 461 | | } |
| | 0 | 462 | | } |
| | | 463 | | |
| | | 464 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 465 | | public async Task<IActionResult> ReplaceMlAutoCartItemOffer(long cartItemId, string? storageCode, CancellationToken |
| | | 466 | | { |
| | 0 | 467 | | if (string.IsNullOrWhiteSpace(storageCode)) return BadRequest(new { error = "Выберите актуальное предложение ML- |
| | | 468 | | try |
| | | 469 | | { |
| | 0 | 470 | | if (!await _mlAutoCart.ReplaceWithRequotedOfferAsync(cartItemId, storageCode, ct)) |
| | 0 | 471 | | return BadRequest(new { error = "Позиция не найдена или оформляется." }); |
| | | 472 | | |
| | 0 | 473 | | await RefreshLocalCartStatusesAsync(ApiSupplierSettings.MlAutoProviderKey, [cartItemId], ct); |
| | 0 | 474 | | return Ok(new { success = true }); |
| | | 475 | | } |
| | 0 | 476 | | catch (Exception ex) when (ex is MlAutoApiException or HttpRequestException or InvalidOperationException) |
| | | 477 | | { |
| | 0 | 478 | | return BadRequest(new { error = ex.Message }); |
| | | 479 | | } |
| | 0 | 480 | | } |
| | | 481 | | |
| | | 482 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 483 | | public async Task<IActionResult> ImportMlAutoOrder(string? orderNumber, CancellationToken ct) |
| | | 484 | | { |
| | 0 | 485 | | if (string.IsNullOrWhiteSpace(orderNumber)) return BadRequest(new { error = "Укажите номер заказа ML-Auto." }); |
| | 0 | 486 | | try { return Ok(await _mlAutoCart.ImportOrderAsync(orderNumber.Trim(), User.Identity?.Name ?? "unknown", ct)); } |
| | 0 | 487 | | catch (MlAutoApiException ex) { return BadRequest(new { error = ex.Message }); } |
| | 0 | 488 | | } |
| | | 489 | | |
| | | 490 | | [HttpGet] |
| | | 491 | | public async Task<IActionResult> MlAutoOrderDetails(string orderNumber, CancellationToken ct) |
| | | 492 | | { |
| | 0 | 493 | | if (string.IsNullOrWhiteSpace(orderNumber)) return BadRequest(new { error = "Не указан номер заказа." }); |
| | 0 | 494 | | try { return Content((await _mlAuto.GetOrderByNumberAsync(orderNumber.Trim(), ct)).ToString(Newtonsoft.Json.Form |
| | 0 | 495 | | catch (MlAutoApiException ex) { return BadRequest(new { error = ex.Message }); } |
| | 0 | 496 | | } |
| | | 497 | | |
| | | 498 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 499 | | public async Task<IActionResult> TestMlAutoRuOrder(long[]? cartItemIds, CancellationToken ct) |
| | | 500 | | { |
| | 0 | 501 | | var result = await _mlAutoRuCart.TestAsync(cartItemIds, User.Identity?.Name ?? "unknown", ct); |
| | 0 | 502 | | await RefreshLocalCartStatusesAsync(ApiSupplierSettings.MlAutoRuProviderKey, cartItemIds, ct); |
| | 0 | 503 | | return Ok(result); |
| | 0 | 504 | | } |
| | | 505 | | |
| | | 506 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 507 | | [SupplierOrderGuard(ApiSupplierSettings.MlAutoRuProviderKey)] |
| | | 508 | | public async Task<IActionResult> CreateMlAutoRuOrder(long[]? cartItemIds, bool cashless, CancellationToken ct) |
| | | 509 | | { |
| | 0 | 510 | | var result = await _mlAutoRuCart.CheckoutAsync(cartItemIds, cashless, User.Identity?.Name ?? "unknown", ct); |
| | 0 | 511 | | await RefreshLocalCartStatusesAsync(ApiSupplierSettings.MlAutoRuProviderKey, cartItemIds, ct); |
| | 0 | 512 | | return Ok(result); |
| | 0 | 513 | | } |
| | | 514 | | |
| | | 515 | | [HttpPost, ValidateAntiForgeryToken] |
| | 0 | 516 | | public async Task<IActionResult> RemoveMlAutoRuCartItems(long[]? cartItemIds, CancellationToken ct) => Ok(new { succ |
| | | 517 | | |
| | | 518 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 519 | | public async Task<IActionResult> UpdateMlAutoRuDeliveryAddress(long cartItemId, string? deliveryAddressId, string? d |
| | | 520 | | { |
| | 0 | 521 | | if (string.IsNullOrWhiteSpace(deliveryAddressId)) return BadRequest(new { error = "Выберите адрес ML-Auto RU." } |
| | 0 | 522 | | if (!await _mlAutoRuCart.UpdateAddressAsync(cartItemId, deliveryAddressId, deliveryAddressName, ct)) |
| | 0 | 523 | | return BadRequest(new { error = "Позиция не найдена или оформляется." }); |
| | | 524 | | |
| | 0 | 525 | | await RefreshLocalCartStatusesAsync(ApiSupplierSettings.MlAutoRuProviderKey, [cartItemId], ct); |
| | 0 | 526 | | return Ok(new { success = true }); |
| | 0 | 527 | | } |
| | | 528 | | |
| | | 529 | | /// <summary>Безопасно проверяет активные позиции локальной корзины Форум-Авто (репрайсинг, не заказ).</summary> |
| | | 530 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 531 | | public async Task<IActionResult> TestForumAutoOrder(string provider, long[]? cartItemIds, CancellationToken ct) |
| | | 532 | | { |
| | 0 | 533 | | if (!IsForumAutoProvider(provider)) |
| | 0 | 534 | | return BadRequest(new { error = "Неизвестный аккаунт Форум-Авто." }); |
| | | 535 | | |
| | 0 | 536 | | var result = await _forumAutoCart.TestAsync(provider, User.Identity?.Name ?? "unknown", cartItemIds, ct); |
| | 0 | 537 | | await RefreshLocalCartStatusesAsync(provider, cartItemIds, ct); |
| | 0 | 538 | | return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 539 | | } |
| | | 540 | | |
| | | 541 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 542 | | [SupplierOrderGuard] |
| | | 543 | | public async Task<IActionResult> CreateForumAutoOrder(string provider, long[]? cartItemIds, CancellationToken ct) |
| | | 544 | | { |
| | 0 | 545 | | if (!IsForumAutoProvider(provider)) |
| | 0 | 546 | | return BadRequest(new { error = "Неизвестный аккаунт Форум-Авто." }); |
| | | 547 | | |
| | 0 | 548 | | var result = await _forumAutoCart.CheckoutAsync(provider, User.Identity?.Name ?? "unknown", cartItemIds, ct); |
| | 0 | 549 | | await RefreshLocalCartStatusesAsync(provider, cartItemIds, ct); |
| | 0 | 550 | | return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 551 | | } |
| | | 552 | | |
| | | 553 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 554 | | public async Task<IActionResult> TestMoskvorechieIstraOrder(long[]? cartItemIds, CancellationToken ct) |
| | | 555 | | { |
| | 0 | 556 | | var result = await _moskvorechieIstraCart.TestAsync(cartItemIds, ct); |
| | 0 | 557 | | return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 558 | | } |
| | | 559 | | |
| | | 560 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 561 | | [SupplierOrderGuard(ApiSupplierSettings.MoskvorechieIstraProviderKey)] |
| | | 562 | | public async Task<IActionResult> CreateMoskvorechieIstraOrder(long[]? cartItemIds, CancellationToken ct) |
| | | 563 | | { |
| | 0 | 564 | | var result = await _moskvorechieIstraCart.CheckoutAsync(User.Identity?.Name ?? "unknown", cartItemIds, ct); |
| | 0 | 565 | | return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 566 | | } |
| | | 567 | | |
| | | 568 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 569 | | public async Task<IActionResult> RemoveMoskvorechieIstraCartItems(long[]? cartItemIds, CancellationToken ct) |
| | | 570 | | { |
| | 0 | 571 | | var result = await _moskvorechieIstraCart.RemoveAsync(cartItemIds, ct); |
| | 0 | 572 | | return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 573 | | } |
| | | 574 | | |
| | | 575 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 576 | | public async Task<IActionResult> TestProfitLigaOrder(long[]? cartItemIds, CancellationToken ct) |
| | | 577 | | { |
| | 0 | 578 | | var result = await _profitLigaCart.TestAsync(cartItemIds ?? [], User.Identity?.Name ?? "unknown", ct); |
| | 0 | 579 | | return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 580 | | } |
| | | 581 | | |
| | | 582 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 583 | | [SupplierOrderGuard(ApiSupplierSettings.ProfitLigaProviderKey)] |
| | | 584 | | public async Task<IActionResult> CreateProfitLigaOrder(long[]? cartItemIds, string? methodId, string? paymentId, |
| | | 585 | | CancellationToken ct) |
| | | 586 | | { |
| | 0 | 587 | | var result = await _profitLigaCart.CheckoutAsync(cartItemIds ?? [], methodId, paymentId, |
| | 0 | 588 | | User.Identity?.Name ?? "unknown", ct); |
| | 0 | 589 | | return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 590 | | } |
| | | 591 | | |
| | | 592 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 593 | | public async Task<IActionResult> RemoveProfitLigaCartItems(bool removeAll, long[]? cartItemIds, CancellationToken ct |
| | | 594 | | { |
| | 0 | 595 | | var result = await _profitLigaCart.RemoveManyAsync(cartItemIds ?? [], removeAll, |
| | 0 | 596 | | User.Identity?.Name ?? "unknown", ct); |
| | 0 | 597 | | return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 598 | | } |
| | | 599 | | |
| | | 600 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 601 | | [SupplierOrderGuard(ApiSupplierSettings.NikeiProviderKey)] |
| | | 602 | | public async Task<IActionResult> CreateNikeiOrder(long[]? cartItemIds, int shippingType, int shippingAddress, |
| | | 603 | | string? orderComment, CancellationToken ct) |
| | | 604 | | { |
| | 0 | 605 | | var result = await _nikeiCart.CheckoutAsync(cartItemIds ?? [], shippingType, shippingAddress, orderComment ?? st |
| | 0 | 606 | | User.Identity?.Name ?? "unknown", ct); |
| | 0 | 607 | | return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 608 | | } |
| | | 609 | | |
| | | 610 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 611 | | public async Task<IActionResult> RemoveNikeiCartItems(bool removeAll, long[]? cartItemIds, CancellationToken ct) |
| | | 612 | | { |
| | 0 | 613 | | var result = await _nikeiCart.RemoveAsync(cartItemIds ?? [], removeAll, User.Identity?.Name ?? "unknown", ct); |
| | 0 | 614 | | return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 615 | | } |
| | | 616 | | |
| | | 617 | | [HttpGet] |
| | | 618 | | public async Task<IActionResult> NikeiDeliveryOptions(CancellationToken ct) |
| | | 619 | | { |
| | | 620 | | try |
| | | 621 | | { |
| | 0 | 622 | | var offices = await _nikeiCart.GetOfficesAsync(ct); |
| | 0 | 623 | | var addresses = await _nikeiCart.GetClientAddressesAsync(ct); |
| | 0 | 624 | | return Ok(new |
| | 0 | 625 | | { |
| | | 626 | | offices = (offices as JArray ?? []).Select(x => new { id = x.Value<int?>("id"), name = x.Value<string>(" |
| | | 627 | | addresses = (addresses["data"] as JArray ?? []).Where(x => x.Value<bool?>("is_active") != false) |
| | | 628 | | .Select(x => new { id = x.Value<int?>("id"), name = x.Value<string>("address"), isDefault = x.Value< |
| | 0 | 629 | | addressesError = addresses.Value<string>("message") |
| | 0 | 630 | | }); |
| | | 631 | | } |
| | 0 | 632 | | catch (NikeiApiException ex) { return BadRequest(new { error = ex.Message }); } |
| | 0 | 633 | | } |
| | | 634 | | |
| | | 635 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 636 | | public async Task<IActionResult> TestMikadoCartItems(long[]? cartItemIds, CancellationToken ct) |
| | | 637 | | { |
| | 0 | 638 | | var result = await _mikadoCart.TestAsync(cartItemIds ?? [], User.Identity?.Name, ct); |
| | 0 | 639 | | return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 640 | | } |
| | | 641 | | |
| | | 642 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 643 | | public async Task<IActionResult> RemoveMikadoCartItems(bool removeAll, long[]? cartItemIds, CancellationToken ct) |
| | | 644 | | { |
| | 0 | 645 | | if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0)) return BadRequest(new { error = "Выберите хо |
| | 0 | 646 | | var result = await _mikadoCart.RemoveAsync(cartItemIds, removeAll, User.Identity?.Name, ct); |
| | 0 | 647 | | return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 648 | | } |
| | | 649 | | |
| | | 650 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 651 | | public async Task<IActionResult> TestZZapMoscowOrder(long[]? cartItemIds, CancellationToken ct) |
| | | 652 | | { |
| | 0 | 653 | | var result = await _zzapCart.TestAsync(cartItemIds, User.Identity?.Name, ct); |
| | 0 | 654 | | return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 655 | | } |
| | | 656 | | |
| | | 657 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 658 | | [SupplierOrderGuard(ApiSupplierSettings.ZZapMoscowProviderKey)] |
| | | 659 | | public async Task<IActionResult> CreateZZapMoscowOrder(long[]? cartItemIds, CancellationToken ct) |
| | | 660 | | { |
| | 0 | 661 | | var result = await _zzapCart.CheckoutAsync(cartItemIds, User.Identity?.Name ?? "unknown", ct); |
| | 0 | 662 | | if (result.Added == 0 && result.Failed == 0) return BadRequest(new { error = string.Join(" ", result.Messages) } |
| | 0 | 663 | | return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 664 | | } |
| | | 665 | | |
| | | 666 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 667 | | public async Task<IActionResult> CancelZZapMoscowOrder(long codeOrder, string? reason, CancellationToken ct) |
| | | 668 | | { |
| | 0 | 669 | | if (codeOrder <= 0) return BadRequest(new { error = "Не указан номер заказа ZZap." }); |
| | 0 | 670 | | var result = await _zzapCart.CancelOrderAsync(codeOrder, reason, User.Identity?.Name ?? "unknown", ct); |
| | 0 | 671 | | if (!result.IsSuccess) return BadRequest(new { error = string.Join(" ", result.Messages) }); |
| | 0 | 672 | | return Ok(new { success = true, result.Messages }); |
| | 0 | 673 | | } |
| | | 674 | | |
| | | 675 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 676 | | public async Task<IActionResult> RemoveZZapMoscowCartItems(bool removeAll, long[]? cartItemIds, CancellationToken ct |
| | | 677 | | { |
| | 0 | 678 | | if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0)) return BadRequest(new { error = "Выберите хо |
| | 0 | 679 | | var result = await _zzapCart.RemoveAsync(cartItemIds, removeAll, User.Identity?.Name, ct); |
| | 0 | 680 | | return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 681 | | } |
| | | 682 | | |
| | | 683 | | /// <summary>Очистка корзины на стороне поставщика: /cart/order оформляет её целиком, |
| | | 684 | | /// поэтому перед заказом она должна быть пустой.</summary> |
| | | 685 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 686 | | public async Task<IActionResult> ClearProfitLigaApiCart(string[]? keys, CancellationToken ct) |
| | | 687 | | { |
| | | 688 | | try |
| | | 689 | | { |
| | 0 | 690 | | var result = await _profitLigaCart.ClearApiCartAsync(keys ?? [], ct); |
| | 0 | 691 | | return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | | 692 | | } |
| | 0 | 693 | | catch (ProfitLigaApiException ex) |
| | | 694 | | { |
| | 0 | 695 | | return BadRequest(new { error = ex.Message }); |
| | | 696 | | } |
| | 0 | 697 | | } |
| | | 698 | | |
| | | 699 | | /// <summary>Проверка заказа Berg: order[is_test]=1 — заказ виден в истории поставщика, |
| | | 700 | | /// но не обрабатывается. Позиции остаются в корзине.</summary> |
| | | 701 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 702 | | public async Task<IActionResult> TestBergOrder(long[]? cartItemIds, CancellationToken ct) |
| | | 703 | | { |
| | 0 | 704 | | var result = await _bergCartService.CheckoutAsync(cartItemIds ?? [], User.Identity?.Name ?? "unknown", |
| | 0 | 705 | | isTest: true, ct); |
| | 0 | 706 | | return Ok(new { success = result.IsSuccess, result.Messages }); |
| | 0 | 707 | | } |
| | | 708 | | |
| | | 709 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 710 | | [SupplierOrderGuard(ApiSupplierSettings.BergProviderKey, nullMeansAll: false)] |
| | | 711 | | public async Task<IActionResult> CreateBergOrder(long[]? cartItemIds, CancellationToken ct) |
| | | 712 | | { |
| | 0 | 713 | | var result = await _bergCartService.CheckoutAsync(cartItemIds ?? [], User.Identity?.Name ?? "unknown", |
| | 0 | 714 | | isTest: false, ct); |
| | 0 | 715 | | return Ok(new { success = result.IsSuccess, result.Messages }); |
| | 0 | 716 | | } |
| | | 717 | | |
| | | 718 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 719 | | public async Task<IActionResult> RemoveBergCartItems(bool removeAll, long[]? cartItemIds, CancellationToken ct) |
| | | 720 | | { |
| | 0 | 721 | | var ids = removeAll |
| | | 722 | | ? (await _bergCartService.GetActiveAsync(ct)).Select(x => x.Id).ToList() |
| | 0 | 723 | | : (cartItemIds ?? []).ToList(); |
| | 0 | 724 | | var removed = await _bergCartService.RemoveAsync(ids, ct); |
| | 0 | 725 | | return Ok(new { success = true, removed }); |
| | 0 | 726 | | } |
| | | 727 | | |
| | | 728 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 729 | | [SupplierOrderGuard(ApiSupplierSettings.MotexProviderKey)] |
| | | 730 | | public async Task<IActionResult> CreateMotexOrder(long[]? cartItemIds, CancellationToken ct) |
| | | 731 | | { |
| | 0 | 732 | | var result = await _motexCart.CheckoutAsync(cartItemIds, User.Identity?.Name ?? "unknown", ct); |
| | 0 | 733 | | return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 734 | | } |
| | | 735 | | |
| | | 736 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 737 | | public async Task<IActionResult> RemoveMotexCartItems(bool removeAll, long[]? cartItemIds, CancellationToken ct) |
| | | 738 | | { |
| | 0 | 739 | | if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0)) return BadRequest(new { error = "Выберите хо |
| | 0 | 740 | | var result = await _motexCart.RemoveAsync(removeAll ? null : cartItemIds, ct); |
| | 0 | 741 | | return Ok(new { success = result.IsSuccess, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 742 | | } |
| | | 743 | | |
| | | 744 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 745 | | public async Task<IActionResult> ImportMoskvorechieIstraOrder(string? externalOrderId, CancellationToken ct) |
| | | 746 | | { |
| | 0 | 747 | | var result = await _moskvorechieIstraCart.ImportOrderAsync(externalOrderId, User.Identity?.Name ?? "unknown", ct |
| | 0 | 748 | | return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 749 | | } |
| | | 750 | | |
| | | 751 | | /// <summary>Удаляет выбранные позиции локальной корзины Форум-Авто либо очищает её целиком.</summary> |
| | | 752 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 753 | | public async Task<IActionResult> RemoveForumAutoCartItems(string provider, bool removeAll, long[]? cartItemIds, Canc |
| | | 754 | | { |
| | 0 | 755 | | if (!IsForumAutoProvider(provider)) |
| | 0 | 756 | | return BadRequest(new { error = "Неизвестный аккаунт Форум-Авто." }); |
| | 0 | 757 | | if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0)) |
| | 0 | 758 | | return BadRequest(new { error = "Выберите хотя бы одну позицию корзины." }); |
| | | 759 | | |
| | 0 | 760 | | var result = await _forumAutoCart.RemoveManyAsync(provider, removeAll ? null : cartItemIds, ct); |
| | 0 | 761 | | if (result.Added == 0) |
| | 0 | 762 | | return BadRequest(new { error = string.Join(" ", result.Messages) }); |
| | | 763 | | |
| | 0 | 764 | | return Ok(new { success = true, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 765 | | } |
| | | 766 | | |
| | | 767 | | /// <summary>Свежая сверка выбранных позиций FavoritParts с реальной корзиной (репрайсинг, не заказ). |
| | | 768 | | /// Ключи выбора — составные "goods|warehouseGroup" (см. FavoritPartsApiCartLine.Key), не суррогатный Id: |
| | | 769 | | /// источник истины — сам API, локальная строка корзины не хранится.</summary> |
| | | 770 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 771 | | public async Task<IActionResult> TestFavoritPartsOrder(string provider, string[]? cartItemIds, CancellationToken ct) |
| | | 772 | | { |
| | 0 | 773 | | if (provider != ApiSupplierSettings.FavoritPartsProviderKey) |
| | 0 | 774 | | return BadRequest(new { error = "Неизвестный аккаунт FavoritParts." }); |
| | | 775 | | |
| | 0 | 776 | | var result = await _favoritPartsCart.TestAsync(cartItemIds, ct); |
| | 0 | 777 | | return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 778 | | } |
| | | 779 | | |
| | | 780 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 781 | | [SupplierOrderGuard] |
| | | 782 | | public async Task<IActionResult> CreateFavoritPartsOrder(string provider, string[]? cartItemIds, CancellationToken c |
| | | 783 | | { |
| | 0 | 784 | | if (provider != ApiSupplierSettings.FavoritPartsProviderKey) |
| | 0 | 785 | | return BadRequest(new { error = "Неизвестный аккаунт FavoritParts." }); |
| | | 786 | | |
| | 0 | 787 | | var result = await _favoritPartsCart.CheckoutAsync(User.Identity?.Name ?? "unknown", cartItemIds, ct); |
| | 0 | 788 | | return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 789 | | } |
| | | 790 | | |
| | | 791 | | /// <summary>Удаляет выбранные позиции реальной корзины FavoritParts либо очищает её целиком (построчно, как у АВД). |
| | | 792 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 793 | | public async Task<IActionResult> RemoveFavoritPartsCartItems(string provider, bool removeAll, string[]? cartItemIds, |
| | | 794 | | { |
| | 0 | 795 | | if (provider != ApiSupplierSettings.FavoritPartsProviderKey) |
| | 0 | 796 | | return BadRequest(new { error = "Неизвестный аккаунт FavoritParts." }); |
| | 0 | 797 | | if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0)) |
| | 0 | 798 | | return BadRequest(new { error = "Выберите хотя бы одну позицию корзины." }); |
| | | 799 | | |
| | 0 | 800 | | var result = await _favoritPartsCart.RemoveManyAsync(removeAll ? null : cartItemIds, removeAll, ct); |
| | 0 | 801 | | if (result.Added == 0) |
| | 0 | 802 | | return BadRequest(new { error = string.Join(" ", result.Messages) }); |
| | | 803 | | |
| | 0 | 804 | | return Ok(new { success = true, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 805 | | } |
| | | 806 | | |
| | | 807 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 808 | | public async Task<IActionResult> TestFavoritPartsIstraOrder(string provider, string[]? cartItemIds, CancellationToke |
| | | 809 | | { |
| | 0 | 810 | | if (provider != ApiSupplierSettings.FavoritPartsIstraProviderKey) return BadRequest(new { error = "Неизвестный а |
| | 0 | 811 | | var result = await _favoritPartsIstraCart.TestAsync(cartItemIds, ct); |
| | 0 | 812 | | return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 813 | | } |
| | | 814 | | |
| | | 815 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 816 | | [SupplierOrderGuard] |
| | | 817 | | public async Task<IActionResult> CreateFavoritPartsIstraOrder(string provider, string[]? cartItemIds, CancellationTo |
| | | 818 | | { |
| | 0 | 819 | | if (provider != ApiSupplierSettings.FavoritPartsIstraProviderKey) return BadRequest(new { error = "Неизвестный а |
| | 0 | 820 | | var result = await _favoritPartsIstraCart.CheckoutAsync(User.Identity?.Name ?? "unknown", cartItemIds, ct); |
| | 0 | 821 | | return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 822 | | } |
| | | 823 | | |
| | | 824 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 825 | | public async Task<IActionResult> RemoveFavoritPartsIstraCartItems(string provider, bool removeAll, string[]? cartIte |
| | | 826 | | { |
| | 0 | 827 | | if (provider != ApiSupplierSettings.FavoritPartsIstraProviderKey) return BadRequest(new { error = "Неизвестный а |
| | 0 | 828 | | if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0)) return BadRequest(new { error = "Выберите хо |
| | 0 | 829 | | var result = await _favoritPartsIstraCart.RemoveManyAsync(removeAll ? null : cartItemIds, removeAll, ct); |
| | 0 | 830 | | return result.Added == 0 ? BadRequest(new { error = string.Join(" ", result.Messages) }) : Ok(new { success = tr |
| | 0 | 831 | | } |
| | | 832 | | |
| | | 833 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 834 | | public async Task<IActionResult> TestFavoritPartsRostovOrder(string provider, string[]? cartItemIds, CancellationTok |
| | | 835 | | { |
| | 0 | 836 | | if (provider != ApiSupplierSettings.FavoritPartsRostovProviderKey) return BadRequest(new { error = "Неизвестный |
| | 0 | 837 | | var result = await _favoritPartsRostovCart.TestAsync(cartItemIds, ct); |
| | 0 | 838 | | return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 839 | | } |
| | | 840 | | |
| | | 841 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 842 | | [SupplierOrderGuard] |
| | | 843 | | public async Task<IActionResult> CreateFavoritPartsRostovOrder(string provider, string[]? cartItemIds, CancellationT |
| | | 844 | | { |
| | 0 | 845 | | if (provider != ApiSupplierSettings.FavoritPartsRostovProviderKey) return BadRequest(new { error = "Неизвестный |
| | 0 | 846 | | var result = await _favoritPartsRostovCart.CheckoutAsync(User.Identity?.Name ?? "unknown", cartItemIds, ct); |
| | 0 | 847 | | return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 848 | | } |
| | | 849 | | |
| | | 850 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 851 | | public async Task<IActionResult> RemoveFavoritPartsRostovCartItems(string provider, bool removeAll, string[]? cartIt |
| | | 852 | | { |
| | 0 | 853 | | if (provider != ApiSupplierSettings.FavoritPartsRostovProviderKey) return BadRequest(new { error = "Неизвестный |
| | 0 | 854 | | if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0)) return BadRequest(new { error = "Выберите хо |
| | 0 | 855 | | var result = await _favoritPartsRostovCart.RemoveManyAsync(removeAll ? null : cartItemIds, removeAll, ct); |
| | 0 | 856 | | return result.Added == 0 ? BadRequest(new { error = string.Join(" ", result.Messages) }) : Ok(new { success = tr |
| | 0 | 857 | | } |
| | | 858 | | |
| | | 859 | | /// <summary>Оформляет всю корзину АВД либо явно выбранные строки после подтверждения в UI.</summary> |
| | | 860 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 861 | | [SupplierOrderGuard(ApiSupplierSettings.AvdProviderKey)] |
| | | 862 | | public async Task<IActionResult> CreateAvdOrder( |
| | | 863 | | bool createAll, |
| | | 864 | | long[]? basketItemIds, |
| | | 865 | | string? comment, |
| | | 866 | | CancellationToken ct) |
| | | 867 | | { |
| | | 868 | | try |
| | | 869 | | { |
| | 0 | 870 | | var currentItems = await _avd.GetBasketItemsAsync(ct); |
| | 0 | 871 | | if (currentItems.Count == 0) |
| | 0 | 872 | | return BadRequest(new { error = "Корзина АВД пуста. Обновите страницу и повторите попытку." }); |
| | | 873 | | |
| | | 874 | | long externalOrderId; |
| | | 875 | | int itemsCount; |
| | | 876 | | // Строки, ушедшие в заказ: по ним привязываем заказы Studio2 к номеру заказа АВД. |
| | | 877 | | // Комментарий позиции уже содержит Id заказа, поэтому повторный запрос к API не нужен. |
| | | 878 | | IReadOnlyList<AvdBasketItem> orderedItems; |
| | 0 | 879 | | if (createAll) |
| | | 880 | | { |
| | 0 | 881 | | externalOrderId = await _avd.CreateOrderAsync(comment, ct); |
| | 0 | 882 | | itemsCount = currentItems.Count; |
| | 0 | 883 | | orderedItems = currentItems; |
| | | 884 | | } |
| | | 885 | | else |
| | | 886 | | { |
| | | 887 | | var requestedIds = (basketItemIds ?? Array.Empty<long>()).Where(id => id > 0).Distinct().ToArray(); |
| | 0 | 888 | | if (requestedIds.Length == 0) |
| | 0 | 889 | | return BadRequest(new { error = "Выберите хотя бы одну позицию корзины." }); |
| | | 890 | | |
| | 0 | 891 | | var actualIds = currentItems |
| | | 892 | | .Select(item => long.TryParse(item.Id, out var id) ? id : (long?)null) |
| | 0 | 893 | | .OfType<long>() |
| | 0 | 894 | | .ToHashSet(); |
| | 0 | 895 | | if (requestedIds.Any(id => !actualIds.Contains(id))) |
| | 0 | 896 | | return Conflict(new { error = "Корзина АВД изменилась. Обновите страницу перед оформлением." }); |
| | | 897 | | |
| | 0 | 898 | | externalOrderId = await _avd.CreateOrderByBasketIdsAsync(requestedIds, comment, ct); |
| | 0 | 899 | | itemsCount = requestedIds.Length; |
| | 0 | 900 | | orderedItems = currentItems |
| | 0 | 901 | | .Where(item => long.TryParse(item.Id, out var id) && requestedIds.Contains(id)) |
| | 0 | 902 | | .ToList(); |
| | 0 | 903 | | } |
| | | 904 | | |
| | 0 | 905 | | await TrackAvdOrderedAsync(orderedItems, externalOrderId, ct); |
| | | 906 | | |
| | 0 | 907 | | _cache.Remove(SupplierCartCacheKeys.AvdBasketCount); |
| | 0 | 908 | | return Ok(new { success = true, orderId = externalOrderId, itemsCount }); |
| | | 909 | | } |
| | 0 | 910 | | catch (AvdApiException ex) |
| | | 911 | | { |
| | 0 | 912 | | return BadRequest(new { error = ex.Message }); |
| | | 913 | | } |
| | 0 | 914 | | } |
| | | 915 | | |
| | | 916 | | /// <summary>Удаляет позиции корзины АВД: у API нет операции «очистить», удаление идёт построчно.</summary> |
| | | 917 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 918 | | public async Task<IActionResult> RemoveAvdBasketItems(bool removeAll, long[]? basketItemIds, CancellationToken ct) |
| | | 919 | | { |
| | | 920 | | try |
| | | 921 | | { |
| | 0 | 922 | | var currentItems = await _avd.GetBasketItemsAsync(ct); |
| | 0 | 923 | | var currentIds = currentItems |
| | | 924 | | .Select(item => long.TryParse(item.Id, out var id) ? id : (long?)null) |
| | 0 | 925 | | .OfType<long>() |
| | 0 | 926 | | .ToArray(); |
| | 0 | 927 | | if (currentIds.Length == 0) |
| | 0 | 928 | | return BadRequest(new { error = "Корзина АВД пуста." }); |
| | | 929 | | |
| | | 930 | | long[] targetIds; |
| | 0 | 931 | | if (removeAll) |
| | | 932 | | { |
| | 0 | 933 | | targetIds = currentIds; |
| | | 934 | | } |
| | | 935 | | else |
| | | 936 | | { |
| | | 937 | | targetIds = (basketItemIds ?? []).Where(id => id > 0).Distinct().ToArray(); |
| | 0 | 938 | | if (targetIds.Length == 0) |
| | 0 | 939 | | return BadRequest(new { error = "Выберите хотя бы одну позицию корзины." }); |
| | 0 | 940 | | if (targetIds.Any(id => !currentIds.Contains(id))) |
| | 0 | 941 | | return Conflict(new { error = "Корзина АВД изменилась. Обновите страницу перед удалением." }); |
| | | 942 | | } |
| | | 943 | | |
| | 0 | 944 | | var removed = 0; |
| | 0 | 945 | | var messages = new List<string>(); |
| | 0 | 946 | | var itemsById = currentItems |
| | | 947 | | .Where(item => long.TryParse(item.Id, out _)) |
| | | 948 | | .ToDictionary(item => long.Parse(item.Id), item => item); |
| | 0 | 949 | | var removedItems = new List<AvdBasketItem>(); |
| | 0 | 950 | | foreach (var id in targetIds) |
| | | 951 | | { |
| | | 952 | | try |
| | | 953 | | { |
| | 0 | 954 | | await _avd.DeleteItemInBasketAsync(id.ToString(), ct); |
| | 0 | 955 | | removed++; |
| | 0 | 956 | | if (itemsById.TryGetValue(id, out var removedItem)) |
| | 0 | 957 | | removedItems.Add(removedItem); |
| | 0 | 958 | | } |
| | 0 | 959 | | catch (AvdApiException ex) |
| | | 960 | | { |
| | 0 | 961 | | messages.Add($"Позиция {id}: {ex.Message}"); |
| | 0 | 962 | | } |
| | | 963 | | } |
| | | 964 | | |
| | 0 | 965 | | await TrackAvdRemovedAsync(removedItems, ct); |
| | 0 | 966 | | _cache.Remove(SupplierCartCacheKeys.AvdBasketCount); |
| | 0 | 967 | | messages.Insert(0, $"Удалено из корзины АВД: {removed} из {targetIds.Length}."); |
| | 0 | 968 | | return Ok(new { success = removed == targetIds.Length, added = removed, failed = targetIds.Length - removed, |
| | | 969 | | } |
| | 0 | 970 | | catch (AvdApiException ex) |
| | | 971 | | { |
| | 0 | 972 | | return BadRequest(new { error = ex.Message }); |
| | | 973 | | } |
| | 0 | 974 | | } |
| | | 975 | | |
| | | 976 | | /// <summary>Удаляет выбранные позиции локальной корзины Armtek либо очищает её целиком.</summary> |
| | | 977 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 978 | | public async Task<IActionResult> RemoveArmtekCartItems(string provider, bool removeAll, long[]? cartItemIds, Cancell |
| | | 979 | | { |
| | 0 | 980 | | if (!IsArmtekProvider(provider)) |
| | 0 | 981 | | return BadRequest(new { error = "Неизвестный поставщик Армтек." }); |
| | | 982 | | // Пустой список без removeAll не должен молча очищать всю корзину. |
| | 0 | 983 | | if (!removeAll && (cartItemIds is null || cartItemIds.Length == 0)) |
| | 0 | 984 | | return BadRequest(new { error = "Выберите хотя бы одну позицию корзины." }); |
| | | 985 | | |
| | 0 | 986 | | var result = await _armtekCart.RemoveManyAsync(provider, removeAll ? null : cartItemIds, ct); |
| | 0 | 987 | | if (result.Added == 0) |
| | 0 | 988 | | return BadRequest(new { error = string.Join(" ", result.Messages) }); |
| | | 989 | | |
| | 0 | 990 | | return Ok(new { success = true, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | 0 | 991 | | } |
| | | 992 | | |
| | | 993 | | /// <summary>Безопасно проверяет все активные позиции локальной корзины Armtek.</summary> |
| | | 994 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 995 | | public async Task<IActionResult> TestArmtekOrder(string provider, long[]? cartItemIds, CancellationToken ct) |
| | | 996 | | { |
| | 0 | 997 | | if (!IsArmtekProvider(provider)) |
| | 0 | 998 | | return BadRequest(new { error = "Неизвестный поставщик Армтек." }); |
| | | 999 | | |
| | | 1000 | | try |
| | | 1001 | | { |
| | 0 | 1002 | | var result = await _armtekCart.TestAsync(provider, User.Identity?.Name ?? "unknown", cartItemIds, ct); |
| | 0 | 1003 | | await RefreshLocalCartStatusesAsync(provider, cartItemIds, ct); |
| | 0 | 1004 | | return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages } |
| | | 1005 | | } |
| | 0 | 1006 | | catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException) |
| | | 1007 | | { |
| | 0 | 1008 | | return BadRequest(new { error = ex.Message }); |
| | | 1009 | | } |
| | 0 | 1010 | | } |
| | | 1011 | | |
| | | 1012 | | /// <summary>Меняет клиента и адрес доставки строки корзины Armtek: подтверждение проверки при этом сбрасывается.</s |
| | | 1013 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 1014 | | public async Task<IActionResult> UpdateArmtekDeliveryAddress(string provider, long cartItemId, string? deliveryOptio |
| | | 1015 | | { |
| | 0 | 1016 | | if (!IsArmtekProvider(provider)) |
| | 0 | 1017 | | return BadRequest(new { error = "Неизвестный поставщик Армтек." }); |
| | | 1018 | | |
| | 0 | 1019 | | var parts = (deliveryOption ?? string.Empty).Split('|'); |
| | 0 | 1020 | | if (parts.Length != 2 || parts.Any(string.IsNullOrWhiteSpace)) |
| | 0 | 1021 | | return BadRequest(new { error = "Выберите клиента и адрес доставки Armtek." }); |
| | | 1022 | | |
| | | 1023 | | IReadOnlyList<ArmtekDeliveryOption> options; |
| | | 1024 | | try |
| | | 1025 | | { |
| | 0 | 1026 | | options = await GetArmtekDeliveryOptionsAsync(provider, ct); |
| | 0 | 1027 | | } |
| | 0 | 1028 | | catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException) |
| | | 1029 | | { |
| | 0 | 1030 | | return BadRequest(new { error = $"Armtek не вернул список адресов: {ex.Message}" }); |
| | | 1031 | | } |
| | | 1032 | | |
| | 0 | 1033 | | var option = options.FirstOrDefault(x => x.BuyerKunnr == parts[0] && x.AddressKunnr == parts[1]); |
| | 0 | 1034 | | if (option is null) |
| | 0 | 1035 | | return BadRequest(new { error = "Такой пары «клиент + адрес» нет в справочнике Armtek." }); |
| | | 1036 | | |
| | 0 | 1037 | | var updated = await _armtekCart.UpdateDeliveryAddressAsync(cartItemId, provider, option.BuyerKunnr, option.Addre |
| | 0 | 1038 | | if (!updated) |
| | 0 | 1039 | | return BadRequest(new { error = "Позиция не найдена или уже оформляется." }); |
| | | 1040 | | |
| | 0 | 1041 | | await RefreshLocalCartStatusesAsync(provider, [cartItemId], ct); |
| | 0 | 1042 | | return Ok(new { success = true, address = option.DisplayName }); |
| | 0 | 1043 | | } |
| | | 1044 | | |
| | | 1045 | | /// <summary>Новая проценка одной отклонённой позиции для её сохранённого адреса доставки.</summary> |
| | | 1046 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 1047 | | public async Task<IActionResult> RequoteArmtekCartItem(string provider, long cartItemId, CancellationToken ct) |
| | | 1048 | | { |
| | 0 | 1049 | | if (!IsArmtekProvider(provider)) return BadRequest(new { error = "Неизвестный поставщик Армтек." }); |
| | | 1050 | | try |
| | | 1051 | | { |
| | 0 | 1052 | | var offers = await _armtekCart.RequoteAsync(provider, cartItemId, ct); |
| | 0 | 1053 | | return Ok(new |
| | 0 | 1054 | | { |
| | 0 | 1055 | | success = true, |
| | 0 | 1056 | | originalWarehouse = offers.OriginalWarehouse, |
| | 0 | 1057 | | deliveryAddress = offers.DeliveryAddress, |
| | | 1058 | | offers = offers.Offers.Select(x => new |
| | | 1059 | | { |
| | | 1060 | | x.Keyzak, x.WarehouseCode, x.WarehouseName, x.Name, x.Price, x.CurrencyCode, x.IsOrderable, |
| | | 1061 | | availableQuantity = x.AvailableQuantity, |
| | | 1062 | | deliveryAt = x.DeliveryAtUtc?.ToString("yyyy-MM-ddTHH:mm:ss") |
| | | 1063 | | }) |
| | 0 | 1064 | | }); |
| | | 1065 | | } |
| | 0 | 1066 | | catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException or InvalidOperationException) |
| | | 1067 | | { |
| | 0 | 1068 | | return BadRequest(new { error = ex.Message }); |
| | | 1069 | | } |
| | 0 | 1070 | | } |
| | | 1071 | | |
| | | 1072 | | /// <summary>Фиксирует выбранный вариант свежей проценки и сбрасывает подтверждение заказа.</summary> |
| | | 1073 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 1074 | | public async Task<IActionResult> ReplaceArmtekCartItemOffer(string provider, long cartItemId, string? keyzak, Cancel |
| | | 1075 | | { |
| | 0 | 1076 | | if (!IsArmtekProvider(provider)) return BadRequest(new { error = "Неизвестный поставщик Армтек." }); |
| | 0 | 1077 | | if (string.IsNullOrWhiteSpace(keyzak)) return BadRequest(new { error = "Выберите актуальное предложение." }); |
| | | 1078 | | try |
| | | 1079 | | { |
| | 0 | 1080 | | var replaced = await _armtekCart.ReplaceWithRequotedOfferAsync(provider, cartItemId, keyzak, ct); |
| | 0 | 1081 | | if (!replaced) |
| | 0 | 1082 | | return BadRequest(new { error = "Позиция не найдена или уже оформляется." }); |
| | | 1083 | | |
| | 0 | 1084 | | await RefreshLocalCartStatusesAsync(provider, [cartItemId], ct); |
| | 0 | 1085 | | return Ok(new { success = true }); |
| | | 1086 | | } |
| | 0 | 1087 | | catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException or InvalidOperationException) |
| | | 1088 | | { |
| | 0 | 1089 | | return BadRequest(new { error = ex.Message }); |
| | | 1090 | | } |
| | 0 | 1091 | | } |
| | | 1092 | | |
| | | 1093 | | /// <summary>Создаёт реальный заказ Armtek по ранее успешно проверенным позициям.</summary> |
| | | 1094 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 1095 | | public async Task<IActionResult> PrepareArmtekCheckout(string provider, long[]? cartItemIds, CancellationToken ct) |
| | | 1096 | | { |
| | 0 | 1097 | | if (!IsArmtekProvider(provider)) |
| | 0 | 1098 | | return BadRequest(new { error = "Неизвестный поставщик Армтек." }); |
| | | 1099 | | |
| | | 1100 | | try |
| | | 1101 | | { |
| | 0 | 1102 | | var userName = User.Identity?.Name ?? "unknown"; |
| | 0 | 1103 | | var result = await _armtekCart.TestAsync(provider, userName, cartItemIds, ct); |
| | 0 | 1104 | | await RefreshLocalCartStatusesAsync(provider, cartItemIds, ct); |
| | 0 | 1105 | | if (result.Failed > 0) |
| | 0 | 1106 | | return Ok(new { success = false, result.Added, result.Skipped, result.Failed, result.Messages }); |
| | | 1107 | | |
| | 0 | 1108 | | var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); |
| | 0 | 1109 | | _cache.Set(ArmtekCheckoutPreparationCacheKey(token), new ArmtekCheckoutPreparation(userName, provider, Norma |
| | 0 | 1110 | | return Ok(new { success = true, checkoutToken = token, result.Added, result.Skipped, result.Failed, result.M |
| | | 1111 | | } |
| | 0 | 1112 | | catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException) |
| | | 1113 | | { |
| | 0 | 1114 | | return BadRequest(new { error = ex.Message }); |
| | | 1115 | | } |
| | 0 | 1116 | | } |
| | | 1117 | | |
| | | 1118 | | /// <summary>Создаёт реальный заказ только после свежей проверки, подготовленной отдельным запросом.</summary> |
| | | 1119 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 1120 | | [SupplierOrderGuard] |
| | | 1121 | | public async Task<IActionResult> CreateArmtekOrder(string provider, long[]? cartItemIds, string? checkoutToken, Canc |
| | | 1122 | | { |
| | 0 | 1123 | | if (!IsArmtekProvider(provider)) |
| | 0 | 1124 | | return BadRequest(new { error = "Неизвестный поставщик Армтек." }); |
| | | 1125 | | |
| | 0 | 1126 | | var userName = User.Identity?.Name ?? "unknown"; |
| | 0 | 1127 | | if (string.IsNullOrWhiteSpace(checkoutToken) || |
| | 0 | 1128 | | !_cache.TryGetValue(ArmtekCheckoutPreparationCacheKey(checkoutToken), out ArmtekCheckoutPreparation? prepara |
| | 0 | 1129 | | preparation is null || preparation.UserName != userName || preparation.Provider != provider || |
| | 0 | 1130 | | !SameCartItemIds(preparation.CartItemIds, cartItemIds)) |
| | 0 | 1131 | | return BadRequest(new { error = "Перед оформлением выполните свежую проверку корзины." }); |
| | | 1132 | | |
| | 0 | 1133 | | _cache.Remove(ArmtekCheckoutPreparationCacheKey(checkoutToken)); |
| | | 1134 | | |
| | | 1135 | | try |
| | | 1136 | | { |
| | 0 | 1137 | | var result = await _armtekCart.CheckoutAsync(provider, userName, cartItemIds, ct); |
| | 0 | 1138 | | await RefreshLocalCartStatusesAsync(provider, cartItemIds, ct); |
| | 0 | 1139 | | if (result.Added == 0 && result.Failed == 0) |
| | 0 | 1140 | | return BadRequest(new { error = string.Join(" ", result.Messages) }); |
| | | 1141 | | |
| | 0 | 1142 | | return Ok(new { success = result.Failed == 0, result.Added, result.Skipped, result.Failed, result.Messages } |
| | | 1143 | | } |
| | 0 | 1144 | | catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException) |
| | | 1145 | | { |
| | 0 | 1146 | | return BadRequest(new { error = ex.Message }); |
| | | 1147 | | } |
| | 0 | 1148 | | } |
| | | 1149 | | |
| | 0 | 1150 | | private static string ArmtekCheckoutPreparationCacheKey(string token) => $"supplier-cart:armtek-checkout:{token}"; |
| | 0 | 1151 | | private static long[]? NormalizeCartItemIds(long[]? ids) => ids is null ? null : ids.Where(id => id > 0).Distinct(). |
| | | 1152 | | private static bool SameCartItemIds(long[]? expected, long[]? actual) => |
| | 0 | 1153 | | expected is null ? actual is null : actual is not null && expected.SequenceEqual(NormalizeCartItemIds(actual)!); |
| | | 1154 | | |
| | | 1155 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 1156 | | public async Task<IActionResult> RemoveShateMCartItems(string[]? cartItemIds, bool removeAll, CancellationToken ct) |
| | | 1157 | | { |
| | | 1158 | | try |
| | | 1159 | | { |
| | 0 | 1160 | | var removed = await _shateMCart.RemoveExternalAsync(cartItemIds, removeAll, ct); |
| | 0 | 1161 | | return Ok(new { success = true, messages = new[] { $"Шате‑М: удалено позиций {removed}." } }); |
| | | 1162 | | } |
| | 0 | 1163 | | catch (Exception ex) when (ex is ShateMApiException or HttpRequestException) |
| | | 1164 | | { |
| | 0 | 1165 | | return BadRequest(new { error = ex.Message }); |
| | | 1166 | | } |
| | 0 | 1167 | | } |
| | | 1168 | | |
| | | 1169 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 1170 | | [SupplierOrderGuard(ApiSupplierSettings.ShateMProviderKey)] |
| | | 1171 | | public async Task<IActionResult> CheckoutShateMCartItems( |
| | | 1172 | | string[]? cartItemIds, bool checkoutAll, string? agreementCode, string? deliveryAddressCode, |
| | | 1173 | | string? deliveryType, string? deliveryAtUtc, bool agreeWithTermsOfDelivery, |
| | | 1174 | | bool agreeWithPersonalDataProcessingPolicyAndUserAgreement, CancellationToken ct) |
| | | 1175 | | { |
| | 0 | 1176 | | if (!TryCreateShateMCheckoutOptions(agreementCode, deliveryAddressCode, deliveryType, deliveryAtUtc, |
| | 0 | 1177 | | agreeWithTermsOfDelivery, agreeWithPersonalDataProcessingPolicyAndUserAgreement, out var options, out va |
| | 0 | 1178 | | return BadRequest(new { error }); |
| | | 1179 | | try |
| | | 1180 | | { |
| | 0 | 1181 | | var result = await _shateMCart.CheckoutExternalAsync(cartItemIds, checkoutAll, options!, User.Identity?.Name |
| | 0 | 1182 | | if (result.Added == 0 && result.Failed == 0) |
| | 0 | 1183 | | return BadRequest(new { error = string.Join(" ", result.Messages) }); |
| | 0 | 1184 | | return Ok(new { success = result.Failed == 0, result.Added, result.Failed, result.Messages }); |
| | | 1185 | | } |
| | 0 | 1186 | | catch (Exception ex) when (ex is ShateMApiException or HttpRequestException or InvalidOperationException) |
| | | 1187 | | { |
| | 0 | 1188 | | return BadRequest(new { error = ex.Message }); |
| | | 1189 | | } |
| | 0 | 1190 | | } |
| | | 1191 | | |
| | | 1192 | | private static bool TryCreateShateMCheckoutOptions(string? agreementCode, string? addressCode, string? deliveryType, |
| | | 1193 | | string? deliveryAtUtc, bool terms, bool personalData, out ShateMCheckoutOptions? options, out string error) |
| | | 1194 | | { |
| | 0 | 1195 | | options = null; |
| | 0 | 1196 | | error = "Выберите договор, адрес, дату доставки и подтвердите согласия Шате‑М."; |
| | 0 | 1197 | | if (string.IsNullOrWhiteSpace(agreementCode) || string.IsNullOrWhiteSpace(addressCode) || string.IsNullOrWhiteSp |
| | 0 | 1198 | | !DateTime.TryParse(deliveryAtUtc, CultureInfo.GetCultureInfo("ru-RU"), DateTimeStyles.AllowWhiteSpaces, out |
| | 0 | 1199 | | return false; |
| | 0 | 1200 | | options = new ShateMCheckoutOptions(agreementCode, addressCode, string.IsNullOrWhiteSpace(deliveryType) ? "OneDe |
| | 0 | 1201 | | return true; |
| | | 1202 | | } |
| | | 1203 | | |
| | | 1204 | | /// <summary>Проксирует миниатюры Ozon, чтобы CDN не скрывал их при прямой загрузке из браузера.</summary> |
| | | 1205 | | [HttpGet] |
| | | 1206 | | public async Task<IActionResult> ImageProxy(string? url, CancellationToken ct) |
| | | 1207 | | { |
| | 0 | 1208 | | if (!TryCreateProductImageUri(url, out var uri)) |
| | 0 | 1209 | | return BadRequest(); |
| | | 1210 | | |
| | | 1211 | | try |
| | | 1212 | | { |
| | 0 | 1213 | | using var response = await _httpClientFactory.CreateClient().GetAsync(uri, ct); |
| | 0 | 1214 | | if (!response.IsSuccessStatusCode) |
| | 0 | 1215 | | return NotFound(); |
| | | 1216 | | |
| | 0 | 1217 | | var contentType = response.Content.Headers.ContentType?.MediaType; |
| | 0 | 1218 | | if (string.IsNullOrWhiteSpace(contentType) || !contentType.StartsWith("image/", StringComparison.OrdinalIgno |
| | 0 | 1219 | | return NotFound(); |
| | | 1220 | | |
| | 0 | 1221 | | var bytes = await response.Content.ReadAsByteArrayAsync(ct); |
| | 0 | 1222 | | return File(bytes, contentType); |
| | | 1223 | | } |
| | 0 | 1224 | | catch (HttpRequestException) |
| | | 1225 | | { |
| | 0 | 1226 | | return NotFound(); |
| | | 1227 | | } |
| | 0 | 1228 | | } |
| | | 1229 | | |
| | 0 | 1230 | | private static bool IsArmtekProvider(string provider) => provider is "Armtek" or "ArmtekBy"; |
| | | 1231 | | |
| | | 1232 | | /// <summary> |
| | | 1233 | | /// Сразу переносит результат действия в локальной корзине в состояние заказа. |
| | | 1234 | | /// Не ждём recurring job: пользователь видит Ready/Pending/Failed в основной таблице |
| | | 1235 | | /// сразу после проверки, смены адреса или выбора свежего предложения. |
| | | 1236 | | /// </summary> |
| | | 1237 | | private async Task RefreshLocalCartStatusesAsync( |
| | | 1238 | | string providerKey, |
| | | 1239 | | IReadOnlyCollection<long>? cartItemIds, |
| | | 1240 | | CancellationToken ct) |
| | | 1241 | | { |
| | | 1242 | | var ids = cartItemIds?.Where(id => id > 0).Distinct().ToArray(); |
| | 0 | 1243 | | var rows = providerKey switch |
| | 0 | 1244 | | { |
| | 0 | 1245 | | "Armtek" or "ArmtekBy" => await _context.ArmtekCartItems |
| | 0 | 1246 | | .AsNoTracking() |
| | 0 | 1247 | | .Where(item => item.ProviderKey == providerKey && item.ActiveStudioOrderId != null && |
| | 0 | 1248 | | (ids == null || ids.Contains(item.Id))) |
| | 0 | 1249 | | .Select(item => new LocalCartStatusRow(item.StudioOrderId, item.Id, item.Status)) |
| | 0 | 1250 | | .ToListAsync(ct), |
| | 0 | 1251 | | "MlAuto" => await _context.MlAutoCartItems |
| | 0 | 1252 | | .AsNoTracking() |
| | 0 | 1253 | | .Where(item => item.ActiveStudioOrderId != null && (ids == null || ids.Contains(item.Id))) |
| | 0 | 1254 | | .Select(item => new LocalCartStatusRow(item.StudioOrderId, item.Id, item.Status)) |
| | 0 | 1255 | | .ToListAsync(ct), |
| | 0 | 1256 | | "MlAutoRu" => await _context.MlAutoRuCartItems |
| | 0 | 1257 | | .AsNoTracking() |
| | 0 | 1258 | | .Where(item => item.ActiveStudioOrderId != null && (ids == null || ids.Contains(item.Id))) |
| | 0 | 1259 | | .Select(item => new LocalCartStatusRow(item.StudioOrderId, item.Id, item.Status)) |
| | 0 | 1260 | | .ToListAsync(ct), |
| | 0 | 1261 | | "ForumAuto" or "ForumAutoInterparts" or "ForumAutoPiter" or "ForumAutoRostov" or "ForumAutoIstra" => await _ |
| | 0 | 1262 | | .AsNoTracking() |
| | 0 | 1263 | | .Where(item => item.ProviderKey == providerKey && item.ActiveStudioOrderId != null && |
| | 0 | 1264 | | (ids == null || ids.Contains(item.Id))) |
| | 0 | 1265 | | .Select(item => new LocalCartStatusRow(item.StudioOrderId, item.Id, item.Status)) |
| | 0 | 1266 | | .ToListAsync(ct), |
| | 0 | 1267 | | "FavoritParts" => await _context.FavoritPartsCartItems |
| | 0 | 1268 | | .AsNoTracking() |
| | 0 | 1269 | | .Where(item => item.ActiveStudioOrderId != null && (ids == null || ids.Contains(item.Id))) |
| | 0 | 1270 | | .Select(item => new LocalCartStatusRow(item.StudioOrderId, item.Id, item.Status)) |
| | 0 | 1271 | | .ToListAsync(ct), |
| | 0 | 1272 | | "ProfitLiga" => await _context.ProfitLigaCartItems |
| | 0 | 1273 | | .AsNoTracking() |
| | 0 | 1274 | | .Where(item => item.ActiveStudioOrderId != null && (ids == null || ids.Contains(item.Id))) |
| | 0 | 1275 | | .Select(item => new LocalCartStatusRow(item.StudioOrderId, item.Id, item.Status)) |
| | 0 | 1276 | | .ToListAsync(ct), |
| | 0 | 1277 | | _ => [] |
| | 0 | 1278 | | }; |
| | | 1279 | | |
| | 0 | 1280 | | foreach (var row in rows) |
| | | 1281 | | { |
| | | 1282 | | try |
| | | 1283 | | { |
| | 0 | 1284 | | await _supplierStateService.MarkInCartAsync( |
| | 0 | 1285 | | row.OrderId, providerKey, row.Status, row.CartItemId, |
| | 0 | 1286 | | user: User.Identity?.Name, ct: ct); |
| | 0 | 1287 | | } |
| | 0 | 1288 | | catch (Exception ex) |
| | | 1289 | | { |
| | 0 | 1290 | | _logger.LogWarning(ex, |
| | 0 | 1291 | | "{Provider}: не удалось сразу обновить статус корзины для заказа {OrderId}", |
| | 0 | 1292 | | providerKey, row.OrderId); |
| | 0 | 1293 | | } |
| | 0 | 1294 | | } |
| | 0 | 1295 | | } |
| | | 1296 | | |
| | 0 | 1297 | | private sealed record LocalCartStatusRow(int OrderId, long CartItemId, string Status); |
| | | 1298 | | |
| | | 1299 | | private static bool TryExtractOrderId(string? comment, out int orderId) => |
| | 0 | 1300 | | StudioOrderCommentLink.TryExtractOrderId(comment, out orderId); |
| | | 1301 | | |
| | | 1302 | | /// <summary> |
| | | 1303 | | /// Привязывает заказы Studio2 к созданному заказу АВД. Раньше номер заказа терялся, |
| | | 1304 | | /// и отследить его состояние у поставщика было нельзя. |
| | | 1305 | | /// </summary> |
| | | 1306 | | private async Task TrackAvdOrderedAsync( |
| | | 1307 | | IReadOnlyList<AvdBasketItem> orderedItems, long externalOrderId, CancellationToken ct) |
| | | 1308 | | { |
| | 0 | 1309 | | var user = User.Identity?.Name; |
| | 0 | 1310 | | foreach (var item in orderedItems) |
| | | 1311 | | { |
| | 0 | 1312 | | if (!TryExtractOrderId(item.Comment, out var studioOrderId)) continue; |
| | | 1313 | | |
| | | 1314 | | try |
| | | 1315 | | { |
| | 0 | 1316 | | await _supplierStateService.MarkOrderedAsync( |
| | 0 | 1317 | | studioOrderId, ApiSupplierSettings.AvdProviderKey, |
| | 0 | 1318 | | externalOrderNumber: externalOrderId.ToString(), |
| | 0 | 1319 | | externalOrderId: externalOrderId.ToString(), |
| | 0 | 1320 | | externalLineId: item.Id, |
| | 0 | 1321 | | user: user, ct: ct); |
| | 0 | 1322 | | } |
| | 0 | 1323 | | catch (Exception ex) |
| | | 1324 | | { |
| | | 1325 | | // Заказ у поставщика уже создан — сбой журнала не повод отдавать пользователю ошибку. |
| | 0 | 1326 | | _logger.LogWarning(ex, |
| | 0 | 1327 | | "АВД: не удалось привязать заказ {OrderId} к заказу поставщика {ExternalOrderId}", |
| | 0 | 1328 | | studioOrderId, externalOrderId); |
| | 0 | 1329 | | } |
| | | 1330 | | } |
| | 0 | 1331 | | } |
| | | 1332 | | |
| | | 1333 | | private async Task TrackAvdRemovedAsync(IReadOnlyList<AvdBasketItem> removedItems, CancellationToken ct) |
| | | 1334 | | { |
| | 0 | 1335 | | var user = User.Identity?.Name; |
| | 0 | 1336 | | foreach (var item in removedItems) |
| | | 1337 | | { |
| | 0 | 1338 | | if (!TryExtractOrderId(item.Comment, out var studioOrderId)) continue; |
| | | 1339 | | |
| | | 1340 | | try |
| | | 1341 | | { |
| | 0 | 1342 | | await _supplierStateService.RemoveFromCartAsync( |
| | 0 | 1343 | | studioOrderId, ApiSupplierSettings.AvdProviderKey, user, ct); |
| | 0 | 1344 | | } |
| | 0 | 1345 | | catch (Exception ex) |
| | | 1346 | | { |
| | 0 | 1347 | | _logger.LogWarning(ex, "АВД: не удалось отметить удаление заказа {OrderId} из корзины", studioOrderId); |
| | 0 | 1348 | | } |
| | | 1349 | | } |
| | 0 | 1350 | | } |
| | | 1351 | | |
| | | 1352 | | private async Task<SupplierCartSection> BuildJapartsSectionAsync(CancellationToken ct) |
| | | 1353 | | { |
| | 0 | 1354 | | var items = await _japarts.GetBasketAsync(ct); |
| | 0 | 1355 | | var apiOrders = await _japarts.GetOrdersAsync(JapartsOrdersIntervalDays, ct); |
| | | 1356 | | var orderIds = items.Select(x => StudioOrderCommentLink.ExtractOrderId(x.Comment)).OfType<int>().Distinct().ToLi |
| | 0 | 1357 | | var orders = orderIds.Count == 0 ? new Dictionary<int, Order>() : await _context.Orders.AsNoTracking() |
| | 0 | 1358 | | .Include(x => x.ProductInfo).Include(x => x.Manufacturer).Include(x => x.OzonClient) |
| | | 1359 | | .Where(x => orderIds.Contains(x.Id)).ToDictionaryAsync(x => x.Id, ct); |
| | 0 | 1360 | | return new SupplierCartSection |
| | 0 | 1361 | | { |
| | 0 | 1362 | | ProviderKey = ApiSupplierSettings.JapartsProviderKey, DisplayName = "Japarts", LogoUrl = "/images/japarts-lo |
| | 0 | 1363 | | Rows = items.Select(x => |
| | 0 | 1364 | | { |
| | 0 | 1365 | | orders.TryGetValue(StudioOrderCommentLink.ExtractOrderId(x.Comment) ?? 0, out var order); |
| | 0 | 1366 | | return new SupplierCartRow { ApiItemId = x.BasketId, ApiCatalogName = x.MakeName, ApiItemName = x.Detail |
| | 0 | 1367 | | ApiItemNumber = x.DetailNum, ApiPrice = x.PriceRub, ApiQuantity = x.Quantity, ApiRegion = x.Country, |
| | 0 | 1368 | | ApiSupplierName = x.SupplierCode, ApiPeriod = x.DeliveryDays, ApiComment = x.Comment, StudioOrderId |
| | 0 | 1369 | | StudioOrderNumber = order?.ShipmentNumber, StudioProductName = order?.ProductName, StudioArticle = o |
| | 0 | 1370 | | StudioManufacturer = order?.Manufacturer?.Name, StudioProductImageUrl = order?.ProductInfo?.ImageUrl |
| | 0 | 1371 | | }).ToList(), |
| | | 1372 | | JapartsOrders = apiOrders.Select(x => new JapartsApiOrderRow(x.GlobalId, x.OrderNumber, x.MakeName, x.Detail |
| | | 1373 | | x.DetailName, x.QuantityOrdered, x.QuantityBought, x.QuantityArrived, x.QuantityShipped, x.OrderPrice, |
| | | 1374 | | x.Status, string.Empty, x.LastUpdated, x.Comment)).ToList() |
| | 0 | 1375 | | }; |
| | 0 | 1376 | | } |
| | | 1377 | | |
| | | 1378 | | /// <summary> |
| | | 1379 | | /// Вкладка Berg: слева локальная корзина (у Berg нет API корзины), справа активные заказы, |
| | | 1380 | | /// которые тянутся прямо из ordering/states/active.json. |
| | | 1381 | | /// </summary> |
| | | 1382 | | private async Task<SupplierCartSection> BuildBergSectionAsync(CancellationToken ct) |
| | | 1383 | | { |
| | 0 | 1384 | | var cartItems = await _bergCartService.GetActiveAsync(ct); |
| | | 1385 | | var cartOrderIds = cartItems.Select(x => x.StudioOrderId).Distinct().ToList(); |
| | 0 | 1386 | | var orders = cartOrderIds.Count == 0 ? new Dictionary<int, Order>() : await _context.Orders.AsNoTracking() |
| | 0 | 1387 | | .Include(x => x.ProductInfo).Include(x => x.Manufacturer).Include(x => x.OzonClient) |
| | | 1388 | | .Where(x => cartOrderIds.Contains(x.Id)).ToDictionaryAsync(x => x.Id, ct); |
| | | 1389 | | |
| | 0 | 1390 | | var rows = cartItems.Select(x => |
| | 0 | 1391 | | { |
| | 0 | 1392 | | orders.TryGetValue(x.StudioOrderId, out var order); |
| | 0 | 1393 | | return new SupplierCartRow |
| | 0 | 1394 | | { |
| | 0 | 1395 | | LocalCartItemId = x.Id, |
| | 0 | 1396 | | ApiItemId = x.Id.ToString(CultureInfo.InvariantCulture), |
| | 0 | 1397 | | ApiCatalogName = x.Brand, |
| | 0 | 1398 | | ApiItemName = x.ProductName ?? string.Empty, |
| | 0 | 1399 | | ApiItemNumber = x.Article, |
| | 0 | 1400 | | ApiPrice = x.SourcePrice, |
| | 0 | 1401 | | ApiQuantity = x.Quantity, |
| | 0 | 1402 | | ApiSupplierName = x.WarehouseName ?? string.Empty, |
| | 0 | 1403 | | ApiComment = x.LastError ?? string.Empty, |
| | 0 | 1404 | | AddedAt = x.CreatedAtUtc, |
| | 0 | 1405 | | StudioOrderId = order?.Id ?? x.StudioOrderId, |
| | 0 | 1406 | | StudioOrderNumber = order?.ShipmentNumber, |
| | 0 | 1407 | | StudioProductName = order?.ProductName, |
| | 0 | 1408 | | StudioArticle = order?.Article, |
| | 0 | 1409 | | StudioManufacturer = order?.Manufacturer?.Name, |
| | 0 | 1410 | | StudioProductImageUrl = order?.ProductInfo?.ImageUrl, |
| | 0 | 1411 | | StudioClientName = order?.OzonClient?.Name |
| | 0 | 1412 | | }; |
| | 0 | 1413 | | }).ToList(); |
| | | 1414 | | |
| | 0 | 1415 | | var section = new SupplierCartSection |
| | 0 | 1416 | | { |
| | 0 | 1417 | | ProviderKey = ApiSupplierSettings.BergProviderKey, |
| | 0 | 1418 | | DisplayName = "Берг", |
| | 0 | 1419 | | LogoUrl = "/images/berg-logo-2026.png", |
| | 0 | 1420 | | Rows = rows |
| | 0 | 1421 | | }; |
| | | 1422 | | |
| | | 1423 | | try |
| | | 1424 | | { |
| | 0 | 1425 | | var apiOrders = BergDataManager.ParseOrders(await _berg.GetActiveOrderStatesAsync(ct)); |
| | | 1426 | | var externalIds = apiOrders.Select(x => x.Id).ToList(); |
| | | 1427 | | // Связь строки Berg с заказом Studio2 хранится у нас: у поставщика её носит только комментарий. |
| | 0 | 1428 | | var links = externalIds.Count == 0 |
| | 0 | 1429 | | ? [] |
| | 0 | 1430 | | : await _context.BergOrderItems.AsNoTracking() |
| | 0 | 1431 | | .Where(x => externalIds.Contains(x.BergOrder!.ExternalOrderId)) |
| | 0 | 1432 | | .Select(x => new { x.BergOrder!.ExternalOrderId, x.Sequence, x.StudioOrderId }) |
| | 0 | 1433 | | .ToListAsync(ct); |
| | | 1434 | | var linkedOrderIds = links.Select(x => x.StudioOrderId).Distinct().ToList(); |
| | 0 | 1435 | | var shipmentNumbers = linkedOrderIds.Count == 0 |
| | 0 | 1436 | | ? new Dictionary<int, string?>() |
| | 0 | 1437 | | : await _context.Orders.AsNoTracking().Where(x => linkedOrderIds.Contains(x.Id)) |
| | | 1438 | | .ToDictionaryAsync(x => x.Id, x => x.ShipmentNumber, ct); |
| | | 1439 | | |
| | 0 | 1440 | | section = section with |
| | 0 | 1441 | | { |
| | 0 | 1442 | | BergOrders = apiOrders.Select(order => new BergApiOrderRow(order.Id, order.Reference, order.IsTest, |
| | 0 | 1443 | | order.CreatedAt, order.DispatchAt, order.ShipmentAddress, order.Comment, |
| | 0 | 1444 | | order.Items.Select(line => |
| | 0 | 1445 | | { |
| | 0 | 1446 | | var link = links.FirstOrDefault(x => x.ExternalOrderId == order.Id && x.Sequence == line.Sequenc |
| | 0 | 1447 | | shipmentNumbers.TryGetValue(link?.StudioOrderId ?? 0, out var shipmentNumber); |
| | 0 | 1448 | | return new BergApiOrderItemRow(line.Sequence, line.Article, line.Brand, line.Name, |
| | 0 | 1449 | | line.WarehouseName, line.Quantity, line.Price, line.StateId, line.StateName, |
| | 0 | 1450 | | line.AssuredTime, line.Comment, link?.StudioOrderId, shipmentNumber); |
| | | 1451 | | }).ToList())).OrderByDescending(x => x.CreatedAt).ToList() |
| | 0 | 1452 | | }; |
| | 0 | 1453 | | } |
| | 0 | 1454 | | catch (Exception ex) when (ex is BergApiException or HttpRequestException) |
| | | 1455 | | { |
| | | 1456 | | // Недоступность Berg гасит только вкладку заказов: локальная корзина остаётся видимой. |
| | 0 | 1457 | | section = section with { OrdersError = ex.Message }; |
| | 0 | 1458 | | } |
| | | 1459 | | |
| | 0 | 1460 | | return section; |
| | 0 | 1461 | | } |
| | | 1462 | | |
| | | 1463 | | private async Task<SupplierCartSection> BuildAvdSectionAsync(IReadOnlyList<AvdBasketItem> items, CancellationToken c |
| | | 1464 | | { |
| | 0 | 1465 | | var orderIds = items |
| | | 1466 | | .Select(x => TryExtractOrderId(x.Comment, out var id) ? id : (int?)null) |
| | | 1467 | | .Where(id => id.HasValue) |
| | | 1468 | | .Select(id => id!.Value) |
| | 0 | 1469 | | .Distinct() |
| | 0 | 1470 | | .ToList(); |
| | | 1471 | | |
| | 0 | 1472 | | var ordersById = orderIds.Count == 0 |
| | 0 | 1473 | | ? new Dictionary<int, Order>() |
| | 0 | 1474 | | : await _context.Orders.AsNoTracking() |
| | 0 | 1475 | | .Include(o => o.ProductInfo) |
| | 0 | 1476 | | .Include(o => o.Manufacturer) |
| | 0 | 1477 | | .Include(o => o.OzonClient) |
| | 0 | 1478 | | .Where(o => orderIds.Contains(o.Id)) |
| | | 1479 | | .ToDictionaryAsync(o => o.Id, ct); |
| | 0 | 1480 | | var addedAtByBasketId = new Dictionary<string, DateTime>(StringComparer.Ordinal); |
| | 0 | 1481 | | if (orderIds.Count > 0) |
| | | 1482 | | { |
| | 0 | 1483 | | var history = await _context.OrderHistories.AsNoTracking() |
| | 0 | 1484 | | .Where(x => orderIds.Contains(x.OrderId) && x.ColumnName == ApiSupplierBasketHistoryEntry.HistoryColumnN |
| | 0 | 1485 | | .Select(x => new { x.ChangedAt, x.NewValue }) |
| | 0 | 1486 | | .ToListAsync(ct); |
| | 0 | 1487 | | foreach (var item in history) |
| | | 1488 | | { |
| | 0 | 1489 | | var placement = ApiSupplierBasketHistoryEntry.TryParse(item.NewValue); |
| | 0 | 1490 | | if (placement is not { Success: true, ProviderKey: "Avd", ApiItemId: { Length: > 0 } basketItemId }) |
| | | 1491 | | continue; |
| | 0 | 1492 | | if (!addedAtByBasketId.TryGetValue(basketItemId, out var addedAt) || item.ChangedAt < addedAt) |
| | 0 | 1493 | | addedAtByBasketId[basketItemId] = item.ChangedAt; |
| | | 1494 | | } |
| | | 1495 | | } |
| | | 1496 | | |
| | 0 | 1497 | | var rows = items.Select(item => |
| | 0 | 1498 | | { |
| | 0 | 1499 | | Order? order = null; |
| | 0 | 1500 | | if (TryExtractOrderId(item.Comment, out var orderId)) |
| | 0 | 1501 | | ordersById.TryGetValue(orderId, out order); |
| | 0 | 1502 | | |
| | 0 | 1503 | | return new SupplierCartRow |
| | 0 | 1504 | | { |
| | 0 | 1505 | | ApiItemId = item.Id, |
| | 0 | 1506 | | ApiCatalogName = item.CatalogName, |
| | 0 | 1507 | | ApiItemName = item.ItemName, |
| | 0 | 1508 | | ApiItemNumber = item.ItemNumber, |
| | 0 | 1509 | | ApiPrice = item.Price, |
| | 0 | 1510 | | ApiQuantity = item.Quantity, |
| | 0 | 1511 | | ApiRegion = item.Region, |
| | 0 | 1512 | | ApiSupplierName = item.SupplierName, |
| | 0 | 1513 | | ApiPeriod = item.Period, |
| | 0 | 1514 | | ApiComment = item.Comment, |
| | 0 | 1515 | | AddedAt = addedAtByBasketId.TryGetValue(item.Id, out var addedAt) ? addedAt : null, |
| | 0 | 1516 | | StudioOrderId = order?.Id, |
| | 0 | 1517 | | StudioOrderNumber = order?.ShipmentNumber, |
| | 0 | 1518 | | StudioProductName = order?.ProductName, |
| | 0 | 1519 | | StudioArticle = order?.Article, |
| | 0 | 1520 | | StudioManufacturer = order?.Manufacturer?.Name, |
| | 0 | 1521 | | StudioProductImageUrl = order?.ProductInfo?.ImageUrl, |
| | 0 | 1522 | | StudioClientName = order?.OzonClient?.Name |
| | 0 | 1523 | | }; |
| | 0 | 1524 | | }) |
| | 0 | 1525 | | // АВД выдаёт монотонно растущий идентификатор позиции корзины: новые позиции — сверху. |
| | | 1526 | | .OrderByDescending(row => GetBasketItemSortKey(row.ApiItemId)) |
| | | 1527 | | .ThenByDescending(row => row.ApiItemId, StringComparer.Ordinal) |
| | 0 | 1528 | | .ToList(); |
| | | 1529 | | |
| | 0 | 1530 | | return new SupplierCartSection |
| | 0 | 1531 | | { |
| | 0 | 1532 | | ProviderKey = ApiSupplierSettings.AvdProviderKey, |
| | 0 | 1533 | | DisplayName = "АВД", |
| | 0 | 1534 | | LogoUrl = "/images/avd-logo.svg", |
| | 0 | 1535 | | Rows = rows |
| | 0 | 1536 | | }; |
| | 0 | 1537 | | } |
| | | 1538 | | |
| | | 1539 | | private static long GetBasketItemSortKey(string itemId) => |
| | 0 | 1540 | | long.TryParse(itemId, out var numericId) ? numericId : long.MinValue; |
| | | 1541 | | |
| | | 1542 | | private async Task<SupplierCartSection> BuildArmtekSectionAsync(string providerKey, string displayName, string logoU |
| | | 1543 | | { |
| | 0 | 1544 | | var items = await _armtekCart.GetActiveAsync(providerKey, ct); |
| | 0 | 1545 | | var orders = await _armtekCart.GetOrdersAsync(providerKey, ct); |
| | 0 | 1546 | | var orderWarehouseIds = await _context.Orders.AsNoTracking() |
| | 0 | 1547 | | .Where(x => items.Select(item => item.StudioOrderId).Contains(x.Id)) |
| | 0 | 1548 | | .Select(x => new { x.Id, x.ShipmentWarehouseId }) |
| | | 1549 | | .ToDictionaryAsync(x => x.Id, x => x.ShipmentWarehouseId, ct); |
| | 0 | 1550 | | var mappings = await _context.ArmtekDeliveryAddressMappings.AsNoTracking() |
| | 0 | 1551 | | .Where(x => x.ProviderKey == providerKey) |
| | | 1552 | | .ToDictionaryAsync(x => x.ShipmentWarehouseId, ct); |
| | 0 | 1553 | | var section = BuildArmtekSection(providerKey, displayName, logoUrl, items, orders, item => |
| | 0 | 1554 | | orderWarehouseIds.TryGetValue(item.StudioOrderId, out var warehouseId) && warehouseId.HasValue && |
| | 0 | 1555 | | mappings.TryGetValue(warehouseId.Value, out var mapping) && |
| | 0 | 1556 | | mapping.BuyerKunnr == item.BuyerKunnr && mapping.DeliveryAddressKunnr == item.DeliveryAddressKunnr); |
| | | 1557 | | try |
| | | 1558 | | { |
| | 0 | 1559 | | section = section with { ArmtekDeliveryOptions = await GetArmtekDeliveryOptionsAsync(providerKey, ct) }; |
| | 0 | 1560 | | } |
| | 0 | 1561 | | catch (Exception ex) when (ex is ArmtekApiException or HttpRequestException) |
| | | 1562 | | { |
| | 0 | 1563 | | section = section with { ArmtekDeliveryOptionsError = "Armtek не вернул список адресов доставки. Сохранённый |
| | 0 | 1564 | | } |
| | | 1565 | | |
| | | 1566 | | try |
| | | 1567 | | { |
| | 0 | 1568 | | var apiOrders = await GetArmtekApiOrdersAsync(providerKey, createdFrom, createdTo, ct); |
| | 0 | 1569 | | var links = await LoadStudioOrderLinksAsync( |
| | | 1570 | | orders.SelectMany(order => order.Items.Select(item => (order.Vbeln, item.StudioOrderId))), ct); |
| | 0 | 1571 | | return section with { ArmtekApiOrders = apiOrders, StudioOrderLinksBySupplierOrder = links }; |
| | | 1572 | | } |
| | 0 | 1573 | | catch (ArmtekApiException ex) |
| | | 1574 | | { |
| | 0 | 1575 | | return section with { OrdersError = ex.Message }; |
| | | 1576 | | } |
| | 0 | 1577 | | } |
| | | 1578 | | |
| | | 1579 | | private async Task<SupplierCartSection> BuildMlAutoSectionAsync(DateOnly createdFrom, DateOnly createdTo, Cancellati |
| | | 1580 | | { |
| | 0 | 1581 | | var items = await _mlAutoCart.GetActiveAsync(ct); |
| | 0 | 1582 | | var orders = await _mlAutoCart.GetOrdersAsync(ct); |
| | 0 | 1583 | | var mappedWarehouseIds = (await _context.MlAutoDeliveryAddressMappings.AsNoTracking() |
| | 0 | 1584 | | .Select(x => x.ShipmentWarehouseId).ToListAsync(ct)).ToHashSet(); |
| | 0 | 1585 | | var options = new List<MlAutoDeliveryOption>(); |
| | 0 | 1586 | | var apiOrders = new List<MlAutoApiOrderRow>(); |
| | 0 | 1587 | | string? error = null; |
| | 0 | 1588 | | string? ordersError = null; |
| | | 1589 | | try |
| | | 1590 | | { |
| | 0 | 1591 | | var response = await _mlAuto.GetDeliveryAddressesAsync(ct); |
| | 0 | 1592 | | foreach (var item in (response is JContainer container ? container.DescendantsAndSelf() : [response]).OfType |
| | | 1593 | | { |
| | 0 | 1594 | | var id = item.GetValue("ADDRESS_ID", StringComparison.OrdinalIgnoreCase)?.ToString() ?? item.GetValue("I |
| | 0 | 1595 | | if (!string.IsNullOrWhiteSpace(id)) options.Add(new MlAutoDeliveryOption(id, item.GetValue("ADDRESS_NAME |
| | | 1596 | | } |
| | | 1597 | | options = options.DistinctBy(x => x.Id).ToList(); |
| | 0 | 1598 | | } |
| | 0 | 1599 | | catch (MlAutoApiException ex) { error = ex.Message; } |
| | | 1600 | | |
| | | 1601 | | try |
| | | 1602 | | { |
| | 0 | 1603 | | apiOrders = (await GetMlAutoApiOrdersAsync(createdFrom, createdTo, ct)).ToList(); |
| | 0 | 1604 | | } |
| | 0 | 1605 | | catch (MlAutoApiException ex) |
| | | 1606 | | { |
| | 0 | 1607 | | ordersError = ex.Message; |
| | 0 | 1608 | | } |
| | | 1609 | | |
| | 0 | 1610 | | var studioOrderLinks = await LoadStudioOrderLinksAsync( |
| | | 1611 | | orders.SelectMany(order => order.Items.Select(item => (order.OrderNumber, item.StudioOrderId))), ct); |
| | 0 | 1612 | | return new SupplierCartSection { ProviderKey = "MlAuto", DisplayName = "ML-Auto", LogoUrl = "https://www.ml-auto |
| | 0 | 1613 | | Rows = items.Select(x => new SupplierCartRow { LocalCartItemId = x.Id, ApiItemId = x.Pin, ApiItemNumber = x. |
| | 0 | 1614 | | } |
| | | 1615 | | |
| | | 1616 | | private async Task<SupplierCartSection> BuildMlAutoRuSectionAsync(CancellationToken ct) |
| | | 1617 | | { |
| | 0 | 1618 | | var items = await _mlAutoRuCart.GetActiveAsync(ct); |
| | 0 | 1619 | | var orders = await _mlAutoRuCart.GetOrdersAsync(ct); |
| | 0 | 1620 | | var mappedWarehouseIds = (await _context.MlAutoRuDeliveryAddressMappings.AsNoTracking().Select(x => x.ShipmentWa |
| | 0 | 1621 | | var options = new List<MlAutoDeliveryOption>(); |
| | 0 | 1622 | | var apiOrders = new List<MlAutoApiOrderRow>(); |
| | 0 | 1623 | | string? error = null; |
| | 0 | 1624 | | string? ordersError = null; |
| | | 1625 | | try |
| | | 1626 | | { |
| | 0 | 1627 | | var response = await _mlAutoRu.GetDeliveryAddressesAsync(ct); |
| | 0 | 1628 | | foreach (var address in (response is JContainer container ? container.DescendantsAndSelf() : [response]).OfT |
| | | 1629 | | { |
| | 0 | 1630 | | var id = address.GetValue("ADDRESS_ID", StringComparison.OrdinalIgnoreCase)?.ToString() ?? address.GetVa |
| | 0 | 1631 | | if (!string.IsNullOrWhiteSpace(id)) options.Add(new MlAutoDeliveryOption(id, address.GetValue("ADDRESS_N |
| | | 1632 | | } |
| | 0 | 1633 | | } |
| | 0 | 1634 | | catch (MlAutoApiException ex) { error = ex.Message; } |
| | 0 | 1635 | | try { apiOrders = (await GetMlAutoRuApiOrdersAsync(ct)).ToList(); } |
| | 0 | 1636 | | catch (MlAutoApiException ex) { ordersError = ex.Message; } |
| | 0 | 1637 | | var studioOrderLinks = await LoadStudioOrderLinksAsync( |
| | | 1638 | | orders.SelectMany(order => order.Items.Select(item => (order.OrderNumber, item.StudioOrderId))), ct); |
| | 0 | 1639 | | return new SupplierCartSection |
| | 0 | 1640 | | { |
| | | 1641 | | ProviderKey = "MlAutoRu", DisplayName = "ML-Auto RU", LogoUrl = "https://ml-auto.ru/media/files/settings/ins |
| | 0 | 1642 | | Rows = items.Select(x => new SupplierCartRow { LocalCartItemId = x.Id, ApiItemId = x.Pin, ApiItemNumber = x. |
| | 0 | 1643 | | }; |
| | 0 | 1644 | | } |
| | | 1645 | | |
| | | 1646 | | /// <summary>У Forum-Auto нет отдельного запроса за деталями — listOrders сразу возвращает состав заказа.</summary> |
| | | 1647 | | private async Task<SupplierCartSection> BuildForumAutoSectionAsync(string providerKey, string displayName, |
| | | 1648 | | Services.ApiServices.ForumAutoApi.ForumAutoDataManager client, CancellationToken ct) |
| | | 1649 | | { |
| | 0 | 1650 | | var items = await _forumAutoCart.GetActiveAsync(providerKey, ct); |
| | 0 | 1651 | | var orders = await _forumAutoCart.GetOrdersAsync(providerKey, ct); |
| | 0 | 1652 | | var (apiOrders, ordersError) = await GetForumAutoApiOrdersAsync(providerKey, client, orders, ct); |
| | 0 | 1653 | | var studioOrderLinks = await LoadStudioOrderLinksAsync( |
| | | 1654 | | orders.Where(order => order.Did.HasValue) |
| | | 1655 | | .SelectMany(order => order.Items.Select(item => (order.Did!.Value.ToString(CultureInfo.InvariantCulture) |
| | 0 | 1656 | | return new SupplierCartSection |
| | 0 | 1657 | | { |
| | 0 | 1658 | | ProviderKey = providerKey, DisplayName = displayName, LogoUrl = "/images/forum-auto-logo.svg", CurrencyCode |
| | 0 | 1659 | | ForumAutoOrders = orders, ForumAutoApiOrders = apiOrders, StudioOrderLinksBySupplierOrder = studioOrderLinks |
| | | 1660 | | Rows = items.Select(x => new SupplierCartRow |
| | | 1661 | | { |
| | | 1662 | | LocalCartItemId = x.Id, ApiItemId = x.Tid, ApiItemNumber = x.Article, ApiCatalogName = x.Brand, |
| | | 1663 | | ApiItemName = x.ProductName ?? x.Article, ApiPrice = x.SourcePrice, ApiQuantity = x.Quantity, |
| | | 1664 | | ApiRegion = string.Empty, ApiSupplierName = x.Status, |
| | | 1665 | | ApiPeriod = x.DeliveryAtUtc.HasValue ? Math.Max(0, (int)Math.Ceiling((x.DeliveryAtUtc.Value - DateTime.U |
| | | 1666 | | AddedAt = x.CreatedAtUtc.ToLocalTime(), StudioOrderId = x.StudioOrderId, StudioOrderNumber = x.StudioOrd |
| | | 1667 | | StudioProductName = x.StudioOrder?.ProductName, StudioArticle = x.StudioOrder?.Article, |
| | | 1668 | | StudioManufacturer = x.StudioOrder?.Manufacturer?.Name, StudioProductImageUrl = x.StudioOrder?.ProductIn |
| | | 1669 | | StudioClientName = x.StudioOrder?.OzonClient?.Name |
| | | 1670 | | }).ToList() |
| | 0 | 1671 | | }; |
| | 0 | 1672 | | } |
| | | 1673 | | |
| | | 1674 | | /// <summary>У FavoritParts реальная серверная корзина и реальные заказы — источник истины для |
| | | 1675 | | /// отображения сам API (GET cart/, GET order/), а не локальный кэш: он может разойтись с реальным |
| | | 1676 | | /// состоянием (ручное изменение на сайте поставщика, неудачный чекаут). StudioOrderId читается из |
| | | 1677 | | /// comment строки/позиции (см. StudioOrderCommentLink) — тем же каналом, что уже использует АВД.</summary> |
| | | 1678 | | private Task<SupplierCartSection> BuildFavoritPartsSectionAsync(CancellationToken ct) => |
| | 0 | 1679 | | BuildFavoritPartsSectionAsync(_favoritPartsCart, ApiSupplierSettings.FavoritPartsProviderKey, "Фаворит Союз", ct |
| | | 1680 | | |
| | | 1681 | | private async Task<SupplierCartSection> BuildFavoritPartsSectionAsync(FavoritPartsCartService cartService, string pr |
| | | 1682 | | { |
| | 0 | 1683 | | var rows = new List<SupplierCartRow>(); |
| | 0 | 1684 | | string? error = null; |
| | | 1685 | | try |
| | | 1686 | | { |
| | 0 | 1687 | | var lines = await cartService.GetApiCartAsync(ct); |
| | | 1688 | | // Одна строка корзины может обслуживать несколько заказов Studio2 (cart/add не суммирует |
| | | 1689 | | // количество), поэтому привязка берётся из FavoritPartsCartItems, а не только из comment. |
| | 0 | 1690 | | var links = await cartService.ResolveStudioOrdersAsync(lines, ct); |
| | | 1691 | | var orderIds = links.Values.SelectMany(x => x).Select(x => x.StudioOrderId).Distinct().ToList(); |
| | 0 | 1692 | | var ordersById = orderIds.Count == 0 |
| | 0 | 1693 | | ? new Dictionary<int, Order>() |
| | 0 | 1694 | | : await _context.Orders.AsNoTracking() |
| | 0 | 1695 | | .Include(o => o.ProductInfo).Include(o => o.Manufacturer).Include(o => o.OzonClient) |
| | | 1696 | | .Where(o => orderIds.Contains(o.Id)).ToDictionaryAsync(o => o.Id, ct); |
| | | 1697 | | |
| | 0 | 1698 | | rows = lines.Select(line => |
| | 0 | 1699 | | { |
| | 0 | 1700 | | var lineLinks = links.TryGetValue(line.Key, out var l) ? l : []; |
| | 0 | 1701 | | Order? order = lineLinks.Count > 0 && ordersById.TryGetValue(lineLinks[0].StudioOrderId, out var o) ? o |
| | 0 | 1702 | | var days = line.ShippingDate.HasValue |
| | 0 | 1703 | | ? Math.Max(0, line.ShippingDate.Value.DayNumber - FavoritPartsCartService.MoscowToday.DayNumber) |
| | 0 | 1704 | | : (int?)null; |
| | 0 | 1705 | | var sharedNote = lineLinks.Count > 1 |
| | | 1706 | | ? $" Общая строка для заказов: {string.Join(", ", lineLinks.Select(x => x.StudioOrderId))}." |
| | 0 | 1707 | | : string.Empty; |
| | 0 | 1708 | | return new SupplierCartRow |
| | 0 | 1709 | | { |
| | 0 | 1710 | | LocalCartItemId = null, |
| | 0 | 1711 | | ApiItemId = line.Key, |
| | 0 | 1712 | | ApiCatalogName = line.Brand ?? string.Empty, |
| | 0 | 1713 | | ApiItemName = line.GoodsName ?? line.Number ?? string.Empty, |
| | 0 | 1714 | | ApiItemNumber = line.Number ?? string.Empty, |
| | 0 | 1715 | | ApiPrice = line.Price, |
| | 0 | 1716 | | ApiQuantity = line.Count, |
| | 0 | 1717 | | ApiRegion = line.WarehouseGroupCode ?? string.Empty, |
| | 0 | 1718 | | ApiSupplierName = FavoritPartsCartItemStatus.Pending, |
| | 0 | 1719 | | ApiPeriod = days, |
| | 0 | 1720 | | ApiComment = (line.Comment ?? string.Empty) + sharedNote, |
| | 0 | 1721 | | StudioOrderId = order?.Id, |
| | 0 | 1722 | | StudioOrderNumber = order?.ShipmentNumber, |
| | 0 | 1723 | | StudioProductName = order?.ProductName, |
| | 0 | 1724 | | StudioArticle = order?.Article, |
| | 0 | 1725 | | StudioManufacturer = order?.Manufacturer?.Name, |
| | 0 | 1726 | | StudioProductImageUrl = order?.ProductInfo?.ImageUrl, |
| | 0 | 1727 | | StudioClientName = order?.OzonClient?.Name |
| | 0 | 1728 | | }; |
| | 0 | 1729 | | }).ToList(); |
| | 0 | 1730 | | } |
| | 0 | 1731 | | catch (FavoritPartsApiException ex) |
| | | 1732 | | { |
| | 0 | 1733 | | error = ex.Message; |
| | 0 | 1734 | | } |
| | | 1735 | | |
| | 0 | 1736 | | var (apiOrders, ordersError) = await GetFavoritPartsApiOrdersAsync(cartService, providerKey, ct); |
| | | 1737 | | // Строка заказа поставщика может относиться к нескольким заказам Studio2, а comment хранит только |
| | | 1738 | | // первый из них — недостающие связи добираем из локальных FavoritPartsOrderItems. |
| | 0 | 1739 | | var localOrderLinks = await _context.FavoritPartsOrderItems.AsNoTracking() |
| | 0 | 1740 | | .Where(item => item.FavoritPartsOrder != null && item.FavoritPartsOrder.ProviderKey == providerKey && item.F |
| | 0 | 1741 | | .Select(item => new { ExternalOrderId = item.FavoritPartsOrder!.ExternalOrderId!.Value, item.StudioOrderId } |
| | 0 | 1742 | | .ToListAsync(ct); |
| | 0 | 1743 | | var studioOrderLinks = await LoadStudioOrderLinksAsync( |
| | | 1744 | | apiOrders.SelectMany(order => order.Items |
| | | 1745 | | .Where(item => item.StudioOrderId.HasValue) |
| | | 1746 | | .Select(item => (order.Id.ToString(), item.StudioOrderId!.Value))) |
| | | 1747 | | .Concat(localOrderLinks.Select(link => (link.ExternalOrderId.ToString(), link.StudioOrderId))) |
| | 0 | 1748 | | .Distinct(), ct); |
| | | 1749 | | |
| | 0 | 1750 | | return new SupplierCartSection |
| | 0 | 1751 | | { |
| | 0 | 1752 | | ProviderKey = providerKey, DisplayName = displayName, |
| | 0 | 1753 | | LogoUrl = "/images/favorit-parts-logo.png", CurrencyCode = "RUB", |
| | 0 | 1754 | | Rows = rows, Error = error, |
| | 0 | 1755 | | FavoritPartsApiOrders = apiOrders, OrdersError = ordersError, StudioOrderLinksBySupplierOrder = studioOrderL |
| | 0 | 1756 | | }; |
| | 0 | 1757 | | } |
| | | 1758 | | |
| | | 1759 | | /// <summary>Портальная корзина общая: показываем лишь строки, которые были созданы Studio2.</summary> |
| | | 1760 | | private async Task<SupplierCartSection> BuildMoskvorechieIstraSectionAsync(CancellationToken ct) |
| | | 1761 | | { |
| | | 1762 | | try |
| | | 1763 | | { |
| | 0 | 1764 | | await _moskvorechieIstraCart.SynchronizeAsync(ct); |
| | 0 | 1765 | | await _moskvorechieIstraCart.SynchronizeOrdersAsync(ct); |
| | 0 | 1766 | | } |
| | 0 | 1767 | | catch (MoskvorechieApiException ex) |
| | | 1768 | | { |
| | 0 | 1769 | | return SupplierCartSection.Failed(ApiSupplierSettings.MoskvorechieIstraProviderKey, |
| | 0 | 1770 | | "Москворечье (Истра)", "/images/moskvorechie-logo.gif", ex.Message); |
| | | 1771 | | } |
| | 0 | 1772 | | var items = await _context.MoskvorechieIstraCartItems.AsNoTracking() |
| | 0 | 1773 | | // Оформленные строки больше не являются корзиной: они показываются только на вкладке «Заказы». |
| | 0 | 1774 | | .Where(x => x.ActiveStudioOrderId != null && x.Status == "Pending") |
| | 0 | 1775 | | .Include(x => x.StudioOrder).ThenInclude(x => x!.ProductInfo) |
| | 0 | 1776 | | .Include(x => x.StudioOrder).ThenInclude(x => x!.Manufacturer) |
| | 0 | 1777 | | .Include(x => x.StudioOrder).ThenInclude(x => x!.OzonClient) |
| | 0 | 1778 | | .OrderByDescending(x => x.CreatedAtUtc) |
| | 0 | 1779 | | .ToListAsync(ct); |
| | 0 | 1780 | | var orders = await _context.MoskvorechieIstraOrders.AsNoTracking().Include(x => x.Items) |
| | 0 | 1781 | | .OrderByDescending(x => x.CreatedAt) |
| | 0 | 1782 | | .Select(x => new MoskvorechieIstraApiOrderRow(x.Id, x.ExternalOrderNumber, x.CreatedAt, x.Status, |
| | 0 | 1783 | | x.Items.Count, x.Items.Sum(i => (i.Price ?? 0m) * i.Quantity))) |
| | 0 | 1784 | | .ToListAsync(ct); |
| | 0 | 1785 | | return new SupplierCartSection |
| | 0 | 1786 | | { |
| | 0 | 1787 | | ProviderKey = ApiSupplierSettings.MoskvorechieIstraProviderKey, |
| | 0 | 1788 | | DisplayName = "Москворечье (Истра)", LogoUrl = "/images/moskvorechie-logo.gif", CurrencyCode = "RUB", |
| | | 1789 | | Rows = items.Select(x => new SupplierCartRow |
| | | 1790 | | { |
| | | 1791 | | LocalCartItemId = x.Id, ApiItemId = x.ExternalBasketItemId, ApiCatalogName = x.Brand, |
| | | 1792 | | ApiItemName = x.ProductName ?? x.Article, ApiItemNumber = x.Article, ApiPrice = x.SourcePrice, |
| | | 1793 | | ApiQuantity = x.Quantity, ApiRegion = x.Warehouse ?? string.Empty, ApiSupplierName = x.Status, |
| | | 1794 | | ApiPeriod = x.DeliveryDaysMax ?? x.DeliveryDaysMin, ApiComment = x.Comment ?? string.Empty, |
| | | 1795 | | AddedAt = x.CreatedAtUtc, StudioOrderId = x.StudioOrderId, StudioOrderNumber = x.StudioOrder?.ShipmentNu |
| | | 1796 | | StudioProductName = x.StudioOrder?.ProductName, StudioArticle = x.StudioOrder?.Article, |
| | | 1797 | | StudioManufacturer = x.StudioOrder?.Manufacturer?.Name, StudioProductImageUrl = x.StudioOrder?.ProductIn |
| | | 1798 | | StudioClientName = x.StudioOrder?.OzonClient?.Name |
| | | 1799 | | }).ToList(), MoskvorechieIstraOrders = orders |
| | 0 | 1800 | | }; |
| | 0 | 1801 | | } |
| | | 1802 | | |
| | | 1803 | | /// <summary>Гибридная вкладка: рабочая корзина Studio2 плюс реальная корзина аккаунта у поставщика.</summary> |
| | | 1804 | | private async Task<SupplierCartSection> BuildProfitLigaSectionAsync(CancellationToken ct) |
| | | 1805 | | { |
| | 0 | 1806 | | var items = await _profitLigaCart.GetActiveAsync(ct); |
| | 0 | 1807 | | var localOrders = await _profitLigaCart.GetOrdersAsync(ct); |
| | | 1808 | | |
| | 0 | 1809 | | string? error = null; |
| | | 1810 | | try |
| | | 1811 | | { |
| | 0 | 1812 | | var apiLines = await _profitLigaCart.GetApiCartAsync(ct); |
| | 0 | 1813 | | var unknownCount = apiLines.Count(line => !items.Any(item => |
| | 0 | 1814 | | string.Equals(item.ArticleId, line.ArticleId, StringComparison.Ordinal) && |
| | 0 | 1815 | | string.Equals(item.WarehouseId, line.WarehouseId, StringComparison.Ordinal))); |
| | 0 | 1816 | | if (unknownCount > 0) |
| | 0 | 1817 | | error = $"В корзине Профит-Лиги есть {unknownCount} поз. не из Studio2. Удалите их у поставщика: они бло |
| | 0 | 1818 | | } |
| | 0 | 1819 | | catch (Exception ex) when (ex is ProfitLigaApiException or HttpRequestException) |
| | | 1820 | | { |
| | 0 | 1821 | | error = ex.Message; |
| | 0 | 1822 | | } |
| | | 1823 | | |
| | | 1824 | | var orderRows = localOrders.Select(x => new ProfitLigaApiOrderRow( |
| | | 1825 | | x.Id, x.OrderNumber, x.Status, x.CreatedAtUtc, x.PointName, null, x.Items.Count, |
| | | 1826 | | x.Items.Sum(i => (i.Price ?? 0m) * i.Quantity), |
| | | 1827 | | x.Items.Select(i => new ProfitLigaApiOrderItemRow(i.Article, i.Brand, null, i.Quantity, i.Price, |
| | | 1828 | | null, null, null)).ToList())).ToList(); |
| | | 1829 | | |
| | 0 | 1830 | | return new SupplierCartSection |
| | 0 | 1831 | | { |
| | 0 | 1832 | | ProviderKey = ApiSupplierSettings.ProfitLigaProviderKey, |
| | 0 | 1833 | | DisplayName = "Профит-Лига", |
| | 0 | 1834 | | LogoUrl = "/images/profit-liga-logo.svg", |
| | 0 | 1835 | | CurrencyCode = "RUB", |
| | 0 | 1836 | | Error = error, |
| | | 1837 | | Rows = items.Select(x => new SupplierCartRow |
| | | 1838 | | { |
| | | 1839 | | LocalCartItemId = x.Id, |
| | | 1840 | | ApiItemId = x.ArticleId, |
| | | 1841 | | ApiCatalogName = x.Brand, |
| | | 1842 | | ApiItemName = x.ProductName ?? x.Article, |
| | | 1843 | | ApiItemNumber = x.Article, |
| | | 1844 | | ApiPrice = x.SourcePrice, |
| | | 1845 | | ApiQuantity = x.Quantity, |
| | | 1846 | | ApiRegion = x.WarehouseName ?? x.WarehouseId, |
| | | 1847 | | // Общая строка корзины несёт статус в ApiSupplierName, а текст ошибки — в ApiComment. |
| | | 1848 | | ApiSupplierName = x.Status, |
| | | 1849 | | ApiComment = x.LastError ?? string.Empty, |
| | | 1850 | | AddedAt = x.CreatedAtUtc, |
| | | 1851 | | ProfitLigaPointName = x.PointName, |
| | | 1852 | | StudioOrderId = x.StudioOrderId, |
| | | 1853 | | StudioOrderNumber = x.StudioOrder?.ShipmentNumber, |
| | | 1854 | | StudioProductName = x.StudioOrder?.ProductName, |
| | | 1855 | | StudioArticle = x.StudioOrder?.Article, |
| | | 1856 | | StudioClientName = x.StudioOrder?.OzonClient?.Name |
| | | 1857 | | }).ToList(), |
| | 0 | 1858 | | ProfitLigaOrders = orderRows |
| | 0 | 1859 | | }; |
| | 0 | 1860 | | } |
| | | 1861 | | |
| | | 1862 | | private async Task<SupplierCartSection> BuildNikeiSectionAsync(CancellationToken ct) |
| | | 1863 | | { |
| | 0 | 1864 | | var items = await _nikeiCart.GetActiveAsync(ct); |
| | 0 | 1865 | | var linkedOrders = await _context.NikeiCartItems.AsNoTracking().Include(x => x.StudioOrder) |
| | 0 | 1866 | | .Where(x => x.Status == NikeiCartItemStatus.Ordered && x.ExternalOrderNumber != null).ToListAsync(ct); |
| | 0 | 1867 | | string? error = null; |
| | 0 | 1868 | | var orderRows = new List<NikeiApiOrderRow>(); |
| | 0 | 1869 | | var foreignRows = new List<SupplierCartRow>(); |
| | | 1870 | | try |
| | | 1871 | | { |
| | 0 | 1872 | | var apiCart = await _nikeiCart.GetApiCartAsync(ct); |
| | 0 | 1873 | | var contents = apiCart["contents"] as JArray ?? []; |
| | 0 | 1874 | | var foreign = contents.Count(line => !items.Any(x => string.Equals(x.ToOrder, line.Value<string>("to_order") |
| | 0 | 1875 | | if (foreign > 0) error = $"В корзине Nikei есть {foreign} поз., добавленных не из Studio2. Они не будут офор |
| | 0 | 1876 | | foreignRows = contents.Where(line => !items.Any(x => string.Equals(x.ToOrder, line.Value<string>("to_order") |
| | | 1877 | | .Select(line => new SupplierCartRow |
| | | 1878 | | { |
| | | 1879 | | ApiItemId = line.Value<string>("to_order") ?? string.Empty, ApiCatalogName = line.Value<string>("bra |
| | | 1880 | | ApiItemName = line.Value<string>("name") ?? line.Value<string>("article") ?? string.Empty, ApiItemNu |
| | | 1881 | | ApiPrice = line.Value<decimal?>("price"), ApiQuantity = line.Value<decimal?>("quantity"), |
| | | 1882 | | ApiRegion = line.Value<string>("warehouse") ?? "—", ApiSupplierName = "ApiOnly", |
| | | 1883 | | ApiPeriod = line["deliverytime"]?.Value<int?>("max") ?? line["deliverytime"]?.Value<int?>("min"), |
| | | 1884 | | ApiComment = line.Value<string>("comment") ?? "Добавлено в корзину Nikei вне Studio2.", AddedAt = nu |
| | | 1885 | | }).ToList(); |
| | 0 | 1886 | | } |
| | 0 | 1887 | | catch (NikeiApiException ex) { error = ex.Message; } |
| | | 1888 | | |
| | | 1889 | | try |
| | | 1890 | | { |
| | 0 | 1891 | | var apiOrders = await _nikeiCart.GetApiOrdersAsync(ct); |
| | 0 | 1892 | | foreach (var line in (apiOrders["orders"] as JArray ?? [])) |
| | | 1893 | | { |
| | 0 | 1894 | | var invoice = line.Value<string>("invoice") ?? "—"; |
| | 0 | 1895 | | var linked = linkedOrders.FirstOrDefault(x => string.Equals(x.ExternalOrderNumber, invoice, StringCompar |
| | 0 | 1896 | | orderRows.Add(new NikeiApiOrderRow(invoice, line.Value<int?>("id"), line.Value<string>("article") ?? str |
| | 0 | 1897 | | line.Value<string>("brand") ?? string.Empty, line.Value<string>("name"), line.Value<decimal?>("quant |
| | 0 | 1898 | | line.Value<decimal?>("price"), line.Value<string>("status_name") ?? "—", line.Value<string>("shippin |
| | 0 | 1899 | | line.Value<string>("shipping_address"), ParseNikeiDate(line.Value<string>("date_added")), linked?.St |
| | | 1900 | | } |
| | 0 | 1901 | | } |
| | 0 | 1902 | | catch (NikeiApiException ex) { error ??= ex.Message; } |
| | | 1903 | | |
| | 0 | 1904 | | return new SupplierCartSection |
| | 0 | 1905 | | { |
| | 0 | 1906 | | ProviderKey = ApiSupplierSettings.NikeiProviderKey, DisplayName = "Nikei", LogoUrl = "/images/nikei-logo.svg |
| | | 1907 | | Rows = items.Select(x => new SupplierCartRow |
| | | 1908 | | { |
| | | 1909 | | LocalCartItemId = x.Id, ApiItemId = x.ToOrder ?? string.Empty, ApiCatalogName = x.Brand, |
| | | 1910 | | ApiItemName = x.ProductName ?? x.Article, ApiItemNumber = x.Article, ApiPrice = x.SourcePrice, |
| | | 1911 | | ApiQuantity = x.Quantity, ApiRegion = x.WarehouseName ?? "—", ApiSupplierName = x.Status, |
| | | 1912 | | ApiPeriod = x.DeliveryDaysMax ?? x.DeliveryDaysMin, ApiComment = x.LastError ?? x.Comment ?? string.Empt |
| | | 1913 | | AddedAt = x.CreatedAtUtc, StudioOrderId = x.StudioOrderId, StudioOrderNumber = x.StudioOrder?.ShipmentNu |
| | | 1914 | | StudioProductName = x.StudioOrder?.ProductName, StudioArticle = x.StudioOrder?.Article, |
| | | 1915 | | StudioManufacturer = x.StudioOrder?.Manufacturer?.Name, StudioProductImageUrl = x.StudioOrder?.ProductIn |
| | | 1916 | | StudioClientName = x.StudioOrder?.OzonClient?.Name |
| | | 1917 | | }).Concat(foreignRows).ToList(), NikeiOrders = orderRows |
| | 0 | 1918 | | }; |
| | 0 | 1919 | | } |
| | | 1920 | | |
| | 0 | 1921 | | private static DateTime? ParseNikeiDate(string? value) => DateTime.TryParse(value, CultureInfo.InvariantCulture, |
| | 0 | 1922 | | DateTimeStyles.AssumeLocal, out var parsed) ? parsed : null; |
| | | 1923 | | |
| | | 1924 | | private async Task<SupplierCartSection> BuildMikadoSectionAsync(CancellationToken ct) |
| | | 1925 | | { |
| | 0 | 1926 | | var items = await _mikadoCart.GetActiveAsync(ct); |
| | 0 | 1927 | | return new SupplierCartSection |
| | 0 | 1928 | | { |
| | 0 | 1929 | | ProviderKey = ApiSupplierSettings.MikadoMskHod20ProviderKey, DisplayName = "Микадо МскХод20", |
| | 0 | 1930 | | LogoUrl = "https://mikado-parts.ru/img/MikadoLogo.svg", CurrencyCode = "RUB", |
| | | 1931 | | Rows = items.Select(x => new SupplierCartRow { LocalCartItemId = x.Id, ApiItemId = x.ApiBasketItemId.ToStrin |
| | | 1932 | | ApiCatalogName = x.Brand, ApiItemName = x.ProductName ?? x.Article, ApiPrice = x.SourcePrice, ApiQuantit |
| | | 1933 | | ApiPeriod = x.DeliveryDays, ApiSupplierName = x.Status, ApiComment = x.LastError ?? string.Empty, AddedA |
| | | 1934 | | StudioOrderNumber = x.StudioOrder?.ShipmentNumber, StudioProductName = x.StudioOrder?.ProductName, Studi |
| | 0 | 1935 | | }; |
| | 0 | 1936 | | } |
| | | 1937 | | |
| | | 1938 | | /// <summary>Вкладка ZZap: зеркало Studio2 плюс живые данные API. Корзина сверяется с GET /cart по code_order, |
| | | 1939 | | /// заказы берутся из GET /orders и связываются с заказами Studio2 по тому же коду.</summary> |
| | | 1940 | | private async Task<SupplierCartSection> BuildZZapMoscowSectionAsync(CancellationToken ct) |
| | | 1941 | | { |
| | 0 | 1942 | | var items = await _zzapCart.GetActiveAsync(ct); |
| | 0 | 1943 | | var ordered = await _zzapCart.GetOrderedAsync(ct: ct); |
| | 0 | 1944 | | string? error = null; |
| | 0 | 1945 | | IReadOnlyDictionary<long, TransactionServices.ZZapApiCartLine>? apiCart = null; |
| | 0 | 1946 | | try { apiCart = await _zzapCart.GetApiCartAsync(ct); } |
| | 0 | 1947 | | catch (Exception ex) when (ex is ZZapApiException or HttpRequestException) { error = $"Корзина ZZap не прочитана |
| | | 1948 | | |
| | | 1949 | | // Источник корзины — API ZZap: показываем всё, что лежит в корзине аккаунта, а зеркало Studio2 |
| | | 1950 | | // добавляет связь с заказом и статус проверки. Зеркальные строки, которых у ZZap уже нет, — в хвосте с пометкой |
| | | 1951 | | var mirrorByCode = items.ToDictionary(x => x.CodeOrder); |
| | | 1952 | | SupplierCartRow ToRow(TransactionServices.ZZapApiCartLine? live, ZZapCartItem? local) |
| | | 1953 | | { |
| | 0 | 1954 | | var order = local?.StudioOrder; |
| | 0 | 1955 | | var seller = live?.SellerName ?? local?.SellerName; |
| | 0 | 1956 | | var location = live?.Location ?? local?.SellerLocation; |
| | 0 | 1957 | | return new SupplierCartRow |
| | 0 | 1958 | | { |
| | 0 | 1959 | | LocalCartItemId = local?.Id, ApiItemId = (live?.CodeOrder ?? local!.CodeOrder).ToString(CultureInfo.Inva |
| | 0 | 1960 | | ApiItemNumber = live?.Article ?? local?.Article ?? string.Empty, ApiCatalogName = live?.Brand ?? local?. |
| | 0 | 1961 | | ApiItemName = live?.ProductName ?? local?.ProductName ?? live?.Article ?? local?.Article ?? string.Empty |
| | 0 | 1962 | | ApiPrice = live?.Price ?? local?.SourcePrice, ApiQuantity = live?.Quantity ?? local?.Quantity, |
| | 0 | 1963 | | ApiRegion = string.IsNullOrWhiteSpace(location) ? seller ?? "—" : $"{seller} · {location}", |
| | 0 | 1964 | | ApiPeriod = live?.DeliveryDays ?? local?.DeliveryDays, ApiSupplierName = local?.Status ?? "ApiOnly", |
| | 0 | 1965 | | ApiComment = local?.LastError ?? string.Empty, AddedAt = live?.CreatedAt ?? local?.CreatedAtUtc, |
| | 0 | 1966 | | StudioOrderId = local?.StudioOrderId, StudioOrderNumber = order?.ShipmentNumber, StudioProductName = ord |
| | 0 | 1967 | | StudioArticle = order?.Article, StudioManufacturer = order?.Manufacturer?.Name, StudioProductImageUrl = |
| | 0 | 1968 | | StudioClientName = order?.OzonClient?.Name, ZZapCodeOrder = live?.CodeOrder ?? local?.CodeOrder, |
| | 0 | 1969 | | ZZapInApiCart = apiCart is null ? null : live is not null, ZZapApiPrice = live?.Price, ZZapApiDeliveryTe |
| | 0 | 1970 | | }; |
| | | 1971 | | } |
| | 0 | 1972 | | var rows = new List<SupplierCartRow>(); |
| | 0 | 1973 | | if (apiCart is not null) |
| | | 1974 | | rows.AddRange(apiCart.Values.OrderByDescending(x => x.CreatedAt).Select(line => ToRow(line, mirrorByCode.Get |
| | 0 | 1975 | | rows.AddRange(items.Where(x => apiCart is null || !apiCart.ContainsKey(x.CodeOrder)).Select(local => ToRow(null, |
| | | 1976 | | |
| | 0 | 1977 | | string? ordersError = null; |
| | 0 | 1978 | | var orderRows = new List<ZZapApiOrderRow>(); |
| | | 1979 | | try |
| | | 1980 | | { |
| | | 1981 | | var localByCode = ordered.ToDictionary(x => x.CodeOrder); |
| | 0 | 1982 | | var apiOrders = await _zzapCart.GetApiOrdersAsync(ct: ct); |
| | 0 | 1983 | | var seen = new HashSet<long>(); |
| | 0 | 1984 | | foreach (var line in apiOrders) |
| | | 1985 | | { |
| | 0 | 1986 | | seen.Add(line.CodeOrder); |
| | 0 | 1987 | | localByCode.TryGetValue(line.CodeOrder, out var local); |
| | 0 | 1988 | | orderRows.Add(new ZZapApiOrderRow(line.CodeOrder, line.CreatedAt, line.Article, line.Brand, line.Product |
| | 0 | 1989 | | line.SellerPhone, line.Price, line.QuantityOrdered, line.StatusCode, line.StatusName ?? "—", line.St |
| | 0 | 1990 | | local?.StudioOrderId, local?.StudioOrder?.ShipmentNumber, local?.StudioOrder?.OzonClient?.Name)); |
| | | 1991 | | } |
| | | 1992 | | // Заказы Studio2, которых уже нет на первых страницах API, показываем из зеркала — без живого статуса. |
| | | 1993 | | orderRows.AddRange(ordered.Where(x => !seen.Contains(x.CodeOrder)).Select(x => new ZZapApiOrderRow(x.CodeOrd |
| | | 1994 | | x.ProductName, x.SellerName, null, x.SourcePrice, x.Quantity.ToString(CultureInfo.InvariantCulture), x.E |
| | | 1995 | | x.ExternalStatusName ?? "Заказан у поставщика", null, null, x.StudioOrderId, x.StudioOrder?.ShipmentNumb |
| | 0 | 1996 | | } |
| | 0 | 1997 | | catch (Exception ex) when (ex is ZZapApiException or HttpRequestException) |
| | | 1998 | | { |
| | 0 | 1999 | | ordersError = ex.Message; |
| | | 2000 | | orderRows = ordered.Select(x => new ZZapApiOrderRow(x.CodeOrder, x.OrderedAtUtc, x.Article, x.Brand, x.Produ |
| | | 2001 | | x.SourcePrice, x.Quantity.ToString(CultureInfo.InvariantCulture), x.ExternalStatusCode, x.ExternalStatus |
| | | 2002 | | null, null, x.StudioOrderId, x.StudioOrder?.ShipmentNumber, x.StudioOrder?.OzonClient?.Name)).ToList(); |
| | 0 | 2003 | | } |
| | | 2004 | | |
| | 0 | 2005 | | return new SupplierCartSection |
| | 0 | 2006 | | { |
| | 0 | 2007 | | ProviderKey = ApiSupplierSettings.ZZapMoscowProviderKey, DisplayName = ZZapCartService.DisplayName, LogoUrl |
| | 0 | 2008 | | CurrencyCode = "RUB", Rows = rows, ZZapOrders = orderRows, Error = error, OrdersError = ordersError |
| | 0 | 2009 | | }; |
| | 0 | 2010 | | } |
| | | 2011 | | |
| | | 2012 | | private async Task<SupplierCartSection> BuildMotexSectionAsync(DateOnly from, DateOnly to, CancellationToken ct) |
| | | 2013 | | { |
| | 0 | 2014 | | var items = await _motexCart.GetActiveAsync(ct); |
| | 0 | 2015 | | var localOrders = await _motexCart.GetOrdersAsync(ct); |
| | 0 | 2016 | | var apiOrderRows = new List<MotexApiOrderRow>(); |
| | 0 | 2017 | | string? ordersError = null; |
| | | 2018 | | try |
| | | 2019 | | { |
| | 0 | 2020 | | var apiOrders = await _motex.GetOrdersByDateRangeAsync(from.ToDateTime(TimeOnly.MinValue).ToString("yyyyMMdd |
| | 0 | 2021 | | foreach (var apiOrder in (apiOrders["orders"] as JArray ?? new JArray()).OfType<JObject>()) |
| | | 2022 | | { |
| | 0 | 2023 | | var externalId = apiOrder.Value<string>("id"); |
| | 0 | 2024 | | var local = localOrders.FirstOrDefault(x => string.Equals(x.ExternalOrderId, externalId, StringCompariso |
| | 0 | 2025 | | var status = apiOrder.Value<string>("status") ?? local?.Status ?? "—"; |
| | 0 | 2026 | | if (local is not null) local.Status = status; |
| | 0 | 2027 | | var apiItems = apiOrder["orderItems"] as JArray; |
| | | 2028 | | var amount = apiItems?.OfType<JObject>().Sum(item => (ReadDecimal(item, "price") ?? 0m) * (ReadDecimal(i |
| | 0 | 2029 | | apiOrderRows.Add(new MotexApiOrderRow(local?.Id ?? 0, externalId, apiOrder.Value<string>("clientOrderNum |
| | 0 | 2030 | | status, ParseMotexDate(apiOrder.Value<string>("createdAt")) ?? local?.CreatedAtUtc ?? DateTime.UtcNo |
| | | 2031 | | } |
| | 0 | 2032 | | await _context.SaveChangesAsync(ct); |
| | 0 | 2033 | | } |
| | 0 | 2034 | | catch (Exception ex) when (ex is MotexApiException or HttpRequestException) { ordersError = ex.Message; } |
| | 0 | 2035 | | if (apiOrderRows.Count == 0) |
| | | 2036 | | apiOrderRows = localOrders.Select(x => new MotexApiOrderRow(x.Id, x.ExternalOrderId, x.ClientOrderNum, x.Sta |
| | 0 | 2037 | | return new SupplierCartSection |
| | 0 | 2038 | | { |
| | 0 | 2039 | | ProviderKey = ApiSupplierSettings.MotexProviderKey, DisplayName = "МоТехС", LogoUrl = "/images/motex-logo.pn |
| | | 2040 | | Rows = items.Select(x => new SupplierCartRow { LocalCartItemId = x.Id, ApiItemId = x.StoreCode ?? string.Emp |
| | 0 | 2041 | | MotexOrders = apiOrderRows |
| | 0 | 2042 | | }; |
| | 0 | 2043 | | } |
| | | 2044 | | |
| | | 2045 | | private async Task<int> GetMotexOrderCountAsync(CancellationToken ct) |
| | | 2046 | | { |
| | | 2047 | | try |
| | | 2048 | | { |
| | 0 | 2049 | | var today = DateOnly.FromDateTime(DateTime.Today); |
| | 0 | 2050 | | var response = await _motex.GetOrdersByDateRangeAsync( |
| | 0 | 2051 | | today.AddDays(-30).ToDateTime(TimeOnly.MinValue).ToString("yyyyMMddHHmmss"), |
| | 0 | 2052 | | today.ToDateTime(TimeOnly.MaxValue).ToString("yyyyMMddHHmmss"), null, 1, 1, ct); |
| | 0 | 2053 | | return response.Value<int?>("totalRecords") ?? 0; |
| | | 2054 | | } |
| | 0 | 2055 | | catch (Exception ex) when (ex is MotexApiException or HttpRequestException) |
| | | 2056 | | { |
| | 0 | 2057 | | return await _context.MotexOrders.CountAsync(ct); |
| | | 2058 | | } |
| | 0 | 2059 | | } |
| | | 2060 | | |
| | | 2061 | | private async Task<SupplierCartSection> BuildShateMSectionAsync(CancellationToken ct) |
| | | 2062 | | { |
| | 0 | 2063 | | var localItems = await _shateMCart.GetActiveAsync(ct); |
| | 0 | 2064 | | var localOrders = await _shateMCart.GetOrdersAsync(ct); |
| | | 2065 | | var rows = localItems.Select(x => new SupplierCartRow |
| | | 2066 | | { |
| | | 2067 | | LocalCartItemId = x.Id, ApiItemId = x.ExternalBasketItemId ?? x.PriceId, ApiItemNumber = x.Article, |
| | | 2068 | | ApiCatalogName = x.Brand, ApiItemName = x.ProductName ?? x.Article, ApiPrice = x.SourcePrice, |
| | | 2069 | | ApiQuantity = x.Quantity, ApiRegion = x.LocationCode ?? x.Direction ?? string.Empty, |
| | | 2070 | | ApiSupplierName = x.Status, AddedAt = x.CreatedAtUtc, StudioOrderId = x.StudioOrderId, |
| | | 2071 | | StudioOrderNumber = x.StudioOrder?.ShipmentNumber, StudioProductName = x.StudioOrder?.ProductName, |
| | | 2072 | | StudioArticle = x.StudioOrder?.Article, StudioManufacturer = x.StudioOrder?.Manufacturer?.Name, |
| | | 2073 | | StudioClientName = x.StudioOrder?.OzonClient?.Name |
| | | 2074 | | }).ToList(); |
| | | 2075 | | var orders = localOrders.Select(x => new ShateMApiOrderRow(x.Id, x.ExternalOrderId, x.Status, x.CreatedAtUtc, |
| | | 2076 | | x.Items.Count, x.Items.Sum(i => (i.Price ?? 0m) * i.Quantity))).ToList(); |
| | 0 | 2077 | | string? error = null; |
| | | 2078 | | try |
| | | 2079 | | { |
| | 0 | 2080 | | var cartResponse = await _shateM.GetAsync("/api/v1/cartitems", null, ct); |
| | 0 | 2081 | | foreach (var item in (cartResponse as JArray ?? new JArray()).OfType<JObject>()) |
| | | 2082 | | { |
| | 0 | 2083 | | var externalId = item.Value<string>("id") ?? string.Empty; |
| | 0 | 2084 | | if (rows.Any(x => string.Equals(x.ApiItemId, externalId, StringComparison.Ordinal))) continue; |
| | 0 | 2085 | | var article = item["article"] as JObject; |
| | 0 | 2086 | | rows.Add(new SupplierCartRow |
| | 0 | 2087 | | { |
| | 0 | 2088 | | ApiItemId = externalId, ApiItemNumber = article?.Value<string>("code") ?? string.Empty, |
| | 0 | 2089 | | ApiCatalogName = article?.Value<string>("tradeMarkName") ?? string.Empty, |
| | 0 | 2090 | | ApiItemName = article?.Value<string>("name") ?? string.Empty, |
| | 0 | 2091 | | ApiPrice = ReadDecimal(item, "price", "price.value"), ApiQuantity = ReadDecimal(item, "quantity"), |
| | 0 | 2092 | | ApiRegion = item.Value<string>("locationCode") ?? string.Empty, |
| | 0 | 2093 | | ApiSupplierName = "Pending", AddedAt = item.Value<DateTime?>("createDateTime") |
| | 0 | 2094 | | }); |
| | | 2095 | | } |
| | | 2096 | | |
| | 0 | 2097 | | var ordersResponse = await _shateM.GetAsync("/api/v1/orders", new Dictionary<string, string?> |
| | 0 | 2098 | | { |
| | 0 | 2099 | | ["page"] = "1", ["pageSize"] = "100" |
| | 0 | 2100 | | }, ct); |
| | 0 | 2101 | | var statusResponse = await _shateM.GetAsync("/api/v1/orderItemStatusCodes", null, ct); |
| | 0 | 2102 | | var statusNames = (statusResponse as JArray ?? new JArray()).OfType<JObject>() |
| | | 2103 | | .Where(x => !string.IsNullOrWhiteSpace(x.Value<string>("code"))) |
| | | 2104 | | .ToDictionary(x => x.Value<string>("code")!, x => x.Value<string>("name") ?? x.Value<string>("descriptio |
| | 0 | 2105 | | orders = (ordersResponse as JArray ?? new JArray()).OfType<JObject>().Select(order => |
| | 0 | 2106 | | { |
| | | 2107 | | var id = order.Value<string>("id") ?? string.Empty; |
| | | 2108 | | var local = localOrders.FirstOrDefault(x => string.Equals(x.ExternalOrderId, id, StringComparison.Ordina |
| | | 2109 | | var orderItems = order["orderItems"] as JArray ?? new JArray(); |
| | | 2110 | | var status = orderItems.OfType<JObject>().Select(x => x.Value<string>("statusCode")) |
| | | 2111 | | .Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => statusNames.GetValueOrDefault(x!, $"Статус AP |
| | | 2112 | | .Distinct(StringComparer.Ordinal).ToList(); |
| | | 2113 | | return new ShateMApiOrderRow(local?.Id ?? 0, id, |
| | | 2114 | | status.Count == 0 ? local?.Status ?? "Создан" : string.Join(", ", status), |
| | | 2115 | | order.Value<DateTime?>("createDateTime") ?? local?.CreatedAtUtc ?? DateTime.UtcNow, |
| | | 2116 | | orderItems.Count, ReadDecimal(order, "totalSum") ?? 0m); |
| | | 2117 | | }).OrderByDescending(x => x.CreatedAt).ToList(); |
| | 0 | 2118 | | } |
| | 0 | 2119 | | catch (Exception ex) when (ex is ShateMApiException or HttpRequestException or TaskCanceledException) |
| | | 2120 | | { |
| | 0 | 2121 | | error = ex.Message; |
| | 0 | 2122 | | } |
| | 0 | 2123 | | return new SupplierCartSection |
| | 0 | 2124 | | { |
| | 0 | 2125 | | ProviderKey = ApiSupplierSettings.ShateMProviderKey, DisplayName = "Шате‑М", LogoUrl = "/images/shate-m-logo |
| | 0 | 2126 | | Rows = rows, ShateMOrders = orders, OrdersError = error |
| | 0 | 2127 | | }; |
| | 0 | 2128 | | } |
| | | 2129 | | |
| | | 2130 | | private static decimal? ReadDecimal(JObject source, params string[] fields) |
| | | 2131 | | { |
| | 0 | 2132 | | var raw = fields.Select(field => source.Value<string>(field)).FirstOrDefault(value => !string.IsNullOrWhiteSpace |
| | 0 | 2133 | | return decimal.TryParse(raw?.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var value) ? |
| | | 2134 | | } |
| | | 2135 | | |
| | | 2136 | | /// <summary>Motex отдаёт createdAt уже в местном времени (Минск, UTC+3), а не в UTC — AssumeLocal без сдвига значен |
| | | 2137 | | /// чтобы .ToLocalTime() в представлении не прибавлял смещение повторно.</summary> |
| | | 2138 | | private static DateTime? ParseMotexDate(string? value) => |
| | 0 | 2139 | | DateTime.TryParseExact(value, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out va |
| | | 2140 | | |
| | | 2141 | | /// <summary>Активные заказы одним запросом (active=1&rowsorder=1) + точечная догрузка локально |
| | | 2142 | | /// известных заказов, выпавших из активного списка (закрыты/отменены) — по образцу Forum-Auto.</summary> |
| | | 2143 | | private async Task<(IReadOnlyList<FavoritPartsApiOrderRow> Orders, string? Error)> GetFavoritPartsApiOrdersAsync(Fav |
| | | 2144 | | { |
| | 0 | 2145 | | var rows = new Dictionary<Guid, FavoritPartsApiOrderRow>(); |
| | 0 | 2146 | | string? error = null; |
| | | 2147 | | try |
| | | 2148 | | { |
| | 0 | 2149 | | var response = await cartService.GetApiOrdersAsync(active: true, rowsOrder: true, ct); |
| | 0 | 2150 | | foreach (var order in (response as JArray ?? []).OfType<JObject>()) |
| | | 2151 | | { |
| | 0 | 2152 | | var row = ToFavoritPartsApiOrderRow(order); |
| | 0 | 2153 | | if (row is not null) rows[row.Id] = row; |
| | | 2154 | | } |
| | 0 | 2155 | | } |
| | 0 | 2156 | | catch (FavoritPartsApiException ex) |
| | | 2157 | | { |
| | 0 | 2158 | | error = ex.Message; |
| | 0 | 2159 | | } |
| | | 2160 | | |
| | 0 | 2161 | | var localExternalIds = await _context.FavoritPartsOrders.AsNoTracking() |
| | 0 | 2162 | | .Where(x => x.ProviderKey == providerKey && x.ExternalOrderId != null).Select(x => x.ExternalOrderId!.Value) |
| | 0 | 2163 | | .Distinct().ToListAsync(ct); |
| | 0 | 2164 | | var missing = localExternalIds.Where(id => !rows.ContainsKey(id)).ToList(); |
| | 0 | 2165 | | if (missing.Count > 0) |
| | | 2166 | | { |
| | 0 | 2167 | | var gate = new SemaphoreSlim(4); |
| | 0 | 2168 | | var tasks = missing.Select(async id => |
| | 0 | 2169 | | { |
| | 0 | 2170 | | await gate.WaitAsync(ct); |
| | 0 | 2171 | | try { return (Id: id, Order: await cartService.GetSingleApiOrderAsync(id, ct) as JObject, Error: (string |
| | 0 | 2172 | | catch (FavoritPartsApiException ex) { return (Id: id, Order: (JObject?)null, Error: ex.Message); } |
| | 0 | 2173 | | finally { gate.Release(); } |
| | 0 | 2174 | | }); |
| | 0 | 2175 | | foreach (var (id, order, err) in await Task.WhenAll(tasks)) |
| | | 2176 | | { |
| | 0 | 2177 | | if (order is not null) |
| | | 2178 | | { |
| | 0 | 2179 | | var row = ToFavoritPartsApiOrderRow(order); |
| | 0 | 2180 | | if (row is not null) rows[row.Id] = row; |
| | | 2181 | | } |
| | 0 | 2182 | | else if (err is not null) |
| | | 2183 | | { |
| | 0 | 2184 | | rows[id] = new FavoritPartsApiOrderRow(id, null, null, null, "Неизвестно", string.Empty, 0, Array.Em |
| | | 2185 | | } |
| | | 2186 | | } |
| | 0 | 2187 | | } |
| | | 2188 | | |
| | | 2189 | | return (rows.Values.OrderByDescending(x => x.CreatedAt).ToList(), error); |
| | 0 | 2190 | | } |
| | | 2191 | | |
| | | 2192 | | private static FavoritPartsApiOrderRow? ToFavoritPartsApiOrderRow(JObject order) |
| | | 2193 | | { |
| | 0 | 2194 | | if (!Guid.TryParse(order.Value<string>("id"), out var id)) return null; |
| | 0 | 2195 | | var items = (order["goodsList"] as JArray ?? new JArray()).OfType<JObject>().Select(item => |
| | 0 | 2196 | | { |
| | 0 | 2197 | | var goods = item["goods"] as JObject; |
| | 0 | 2198 | | return new FavoritPartsApiOrderItemRow( |
| | 0 | 2199 | | goods?.Value<string>("brand") ?? string.Empty, |
| | 0 | 2200 | | goods?.Value<string>("number") ?? string.Empty, |
| | 0 | 2201 | | item.Value<decimal?>("count") ?? 0, |
| | 0 | 2202 | | item.Value<decimal?>("price"), |
| | 0 | 2203 | | StudioOrderCommentLink.ExtractOrderId(item.Value<string>("comment")), |
| | 0 | 2204 | | item.Value<decimal?>("countCancel") ?? 0); |
| | 0 | 2205 | | }).ToList(); |
| | | 2206 | | |
| | 0 | 2207 | | return new FavoritPartsApiOrderRow( |
| | 0 | 2208 | | id, |
| | 0 | 2209 | | order.Value<string>("number"), |
| | 0 | 2210 | | order.Value<DateTime?>("date"), |
| | 0 | 2211 | | FavoritPartsPriceMapper.ToMoscowDate(FavoritPartsPriceMapper.ReadOffset(order, "shippingDate"))?.ToDateTime( |
| | 0 | 2212 | | (order["status"] as JObject)?.Value<string>("name") ?? "Неизвестно", |
| | 0 | 2213 | | (order["tradePoint"] as JObject)?.Value<string>("name") ?? string.Empty, |
| | 0 | 2214 | | order.Value<decimal?>("sum") ?? 0, |
| | 0 | 2215 | | items, |
| | 0 | 2216 | | null, |
| | 0 | 2217 | | order.Value<string>("postError")); |
| | | 2218 | | } |
| | | 2219 | | |
| | | 2220 | | /// <summary> |
| | | 2221 | | /// listOrders без did отдаёт только активные (не выданные/не аннулированные) заказы — FaultCode 26 значит "пусто", |
| | | 2222 | | /// Выданные/аннулированные заказы выпадают из этого списка, поэтому для каждого локально известного Did, |
| | | 2223 | | /// не попавшего в активный список, дополнительно запрашиваем его статус точечно по did. |
| | | 2224 | | /// </summary> |
| | | 2225 | | private async Task<(IReadOnlyList<ForumAutoApiOrderRow> Orders, string? Error)> GetForumAutoApiOrdersAsync( |
| | | 2226 | | string providerKey, Services.ApiServices.ForumAutoApi.ForumAutoDataManager client, |
| | | 2227 | | IReadOnlyList<OzonDomains.Models.ForumAutoOrder> localOrders, CancellationToken ct) |
| | | 2228 | | { |
| | 0 | 2229 | | var rows = new Dictionary<int, ForumAutoApiOrderRow>(); |
| | 0 | 2230 | | string? generalError = null; |
| | | 2231 | | try |
| | | 2232 | | { |
| | 0 | 2233 | | var response = await _cache.GetOrCreateAsync($"supplier-cart:forumauto-orders:{providerKey}", async entry => |
| | 0 | 2234 | | { |
| | 0 | 2235 | | entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1); |
| | 0 | 2236 | | return await client.ListOrdersAsync(ct: ct); |
| | 0 | 2237 | | }); |
| | 0 | 2238 | | foreach (var row in (response as JArray ?? []).OfType<JObject>().Select(ToForumAutoApiOrderRow)) |
| | 0 | 2239 | | rows[row.Did] = row; |
| | 0 | 2240 | | } |
| | 0 | 2241 | | catch (Services.ApiServices.ForumAutoApi.ForumAutoApiException ex) when (ex.FaultCode == 26) |
| | | 2242 | | { |
| | | 2243 | | // Активных заказов нет — нормальное пустое состояние. |
| | 0 | 2244 | | } |
| | 0 | 2245 | | catch (Services.ApiServices.ForumAutoApi.ForumAutoApiException ex) |
| | | 2246 | | { |
| | 0 | 2247 | | generalError = ex.Message; |
| | 0 | 2248 | | } |
| | | 2249 | | |
| | 0 | 2250 | | var missingDids = localOrders.Where(o => o.Did.HasValue && !rows.ContainsKey(o.Did.Value)) |
| | | 2251 | | .Select(o => o.Did!.Value).Distinct(); |
| | 0 | 2252 | | foreach (var did in missingDids) |
| | | 2253 | | { |
| | | 2254 | | try |
| | | 2255 | | { |
| | 0 | 2256 | | var response = await client.ListOrdersAsync(did, ct); |
| | 0 | 2257 | | var order = (response as JArray)?.OfType<JObject>().FirstOrDefault() ?? response as JObject; |
| | 0 | 2258 | | if (order is not null) rows[did] = ToForumAutoApiOrderRow(order); |
| | 0 | 2259 | | } |
| | 0 | 2260 | | catch (Services.ApiServices.ForumAutoApi.ForumAutoApiException ex) |
| | | 2261 | | { |
| | 0 | 2262 | | rows[did] = new ForumAutoApiOrderRow(did, null, "Неизвестно", 0, null, Array.Empty<ForumAutoApiOrderItem |
| | 0 | 2263 | | } |
| | | 2264 | | } |
| | | 2265 | | return (rows.Values.OrderByDescending(x => x.CreatedAt ?? DateTime.MinValue).ToList(), generalError); |
| | 0 | 2266 | | } |
| | | 2267 | | |
| | | 2268 | | private static ForumAutoApiOrderRow ToForumAutoApiOrderRow(JObject order) |
| | | 2269 | | { |
| | 0 | 2270 | | var items = (order["arTovs"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>()) |
| | 0 | 2271 | | .Select(x => new ForumAutoApiOrderItemRow( |
| | 0 | 2272 | | x.Value<string>("gid") ?? string.Empty, x.Value<string>("nr") ?? string.Empty, x.Value<string>("brand") |
| | 0 | 2273 | | x.Value<decimal?>("k1") ?? 0, x.Value<decimal?>("k2") ?? 0, x.Value<decimal?>("k3") ?? 0, |
| | 0 | 2274 | | x.Value<decimal?>("price"), x.Value<string>("gname"))) |
| | 0 | 2275 | | .ToList(); |
| | 0 | 2276 | | return new ForumAutoApiOrderRow( |
| | 0 | 2277 | | order.Value<int?>("did") ?? 0, |
| | 0 | 2278 | | ParseForumAutoDate(order.Value<string>("dtc")), |
| | 0 | 2279 | | order.Value<string>("status") ?? string.Empty, |
| | 0 | 2280 | | order.Value<int?>("statusId") ?? 0, |
| | 0 | 2281 | | order.Value<string>("mdt"), |
| | 0 | 2282 | | items, |
| | 0 | 2283 | | null); |
| | | 2284 | | } |
| | | 2285 | | |
| | | 2286 | | private static DateTime? ParseForumAutoDate(string? value) => |
| | 0 | 2287 | | DateTime.TryParseExact(value, "dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var |
| | 0 | 2288 | | : DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var fallback) ? fallback : nul |
| | | 2289 | | |
| | | 2290 | | private async Task<IReadOnlyDictionary<string, IReadOnlyList<SupplierCartStudioOrderLink>>> LoadStudioOrderLinksAsyn |
| | | 2291 | | IEnumerable<(string SupplierOrderNumber, int StudioOrderId)> source, CancellationToken ct) |
| | | 2292 | | { |
| | 0 | 2293 | | var pairs = source |
| | | 2294 | | .Where(x => !string.IsNullOrWhiteSpace(x.SupplierOrderNumber) && x.StudioOrderId > 0) |
| | 0 | 2295 | | .Distinct() |
| | 0 | 2296 | | .ToArray(); |
| | 0 | 2297 | | if (pairs.Length == 0) |
| | 0 | 2298 | | return new Dictionary<string, IReadOnlyList<SupplierCartStudioOrderLink>>(StringComparer.Ordinal); |
| | | 2299 | | |
| | | 2300 | | var studioOrderIds = pairs.Select(x => x.StudioOrderId).Distinct().ToArray(); |
| | 0 | 2301 | | var studioOrders = await _context.Orders.AsNoTracking() |
| | 0 | 2302 | | .Where(order => studioOrderIds.Contains(order.Id)) |
| | 0 | 2303 | | .Select(order => new SupplierCartStudioOrderLink(order.Id, order.ShipmentNumber, order.OzonClient!.Name)) |
| | | 2304 | | .ToDictionaryAsync(order => order.OrderId, ct); |
| | | 2305 | | |
| | 0 | 2306 | | return pairs |
| | 0 | 2307 | | .Where(pair => studioOrders.ContainsKey(pair.StudioOrderId)) |
| | | 2308 | | .GroupBy(pair => pair.SupplierOrderNumber, StringComparer.Ordinal) |
| | 0 | 2309 | | .ToDictionary( |
| | | 2310 | | group => group.Key, |
| | 0 | 2311 | | group => (IReadOnlyList<SupplierCartStudioOrderLink>)group |
| | 0 | 2312 | | .Select(pair => studioOrders[pair.StudioOrderId]) |
| | | 2313 | | .DistinctBy(order => order.OrderId) |
| | | 2314 | | .OrderBy(order => order.ShipmentNumber, StringComparer.Ordinal) |
| | 0 | 2315 | | .ToArray(), |
| | 0 | 2316 | | StringComparer.Ordinal); |
| | 0 | 2317 | | } |
| | | 2318 | | |
| | | 2319 | | private async Task<IReadOnlyList<MlAutoApiOrderRow>> GetMlAutoRuApiOrdersAsync(CancellationToken ct) |
| | | 2320 | | { |
| | 0 | 2321 | | var summaries = await _cache.GetOrCreateAsync("supplier-cart:mlautoru-orders", async entry => |
| | 0 | 2322 | | { |
| | 0 | 2323 | | entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1); |
| | 0 | 2324 | | return await _mlAutoRu.GetOrderSummariesAsync(ct); |
| | 0 | 2325 | | }) ?? []; |
| | 0 | 2326 | | using var limiter = new SemaphoreSlim(MlAutoDetailsConcurrency); |
| | 0 | 2327 | | return await Task.WhenAll(summaries.Take(MlAutoMaxDetailedOrders).Select(async summary => |
| | 0 | 2328 | | { |
| | 0 | 2329 | | await limiter.WaitAsync(ct); |
| | 0 | 2330 | | try |
| | 0 | 2331 | | { |
| | 0 | 2332 | | var items = await _cache.GetOrCreateAsync($"supplier-cart:mlautoru-order:{summary.Number}", async entry |
| | 0 | 2333 | | { |
| | 0 | 2334 | | entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); |
| | 0 | 2335 | | return await _mlAutoRu.GetOrderItemsAsync(summary.Number, ct); |
| | 0 | 2336 | | }) ?? []; |
| | 0 | 2337 | | return ToMlAutoApiOrderRow(summary, items, null); |
| | 0 | 2338 | | } |
| | 0 | 2339 | | catch (MlAutoApiException ex) { return ToMlAutoApiOrderRow(summary, [], ex.Message); } |
| | 0 | 2340 | | finally { limiter.Release(); } |
| | 0 | 2341 | | })); |
| | 0 | 2342 | | } |
| | | 2343 | | |
| | | 2344 | | /// <summary> |
| | | 2345 | | /// Заказы ML-Auto за период. GetMyOrders отдаёт только номер и дату, поэтому состав каждого заказа |
| | | 2346 | | /// дочитывается через GetOrderByNumber — оба запроса read-only и заказ не изменяют. |
| | | 2347 | | /// </summary> |
| | | 2348 | | private async Task<IReadOnlyList<MlAutoApiOrderRow>> GetMlAutoApiOrdersAsync(DateOnly createdFrom, DateOnly createdT |
| | | 2349 | | { |
| | 0 | 2350 | | var summaries = await _cache.GetOrCreateAsync("supplier-cart:mlauto-orders", async entry => |
| | 0 | 2351 | | { |
| | 0 | 2352 | | entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1); |
| | 0 | 2353 | | return await _mlAuto.GetOrderSummariesAsync(ct); |
| | 0 | 2354 | | }) ?? []; |
| | | 2355 | | |
| | 0 | 2356 | | var from = createdFrom.ToDateTime(TimeOnly.MinValue); |
| | 0 | 2357 | | var to = createdTo.ToDateTime(TimeOnly.MaxValue); |
| | 0 | 2358 | | var selected = summaries |
| | 0 | 2359 | | .Where(x => x.CreatedAt is null || (x.CreatedAt.Value >= from && x.CreatedAt.Value <= to)) |
| | 0 | 2360 | | .Take(MlAutoMaxDetailedOrders) |
| | 0 | 2361 | | .ToArray(); |
| | | 2362 | | |
| | 0 | 2363 | | using var limiter = new SemaphoreSlim(MlAutoDetailsConcurrency); |
| | 0 | 2364 | | var rows = await Task.WhenAll(selected.Select(async summary => |
| | 0 | 2365 | | { |
| | 0 | 2366 | | await limiter.WaitAsync(ct); |
| | 0 | 2367 | | try { return await BuildMlAutoOrderRowAsync(summary, ct); } |
| | 0 | 2368 | | finally { limiter.Release(); } |
| | 0 | 2369 | | })); |
| | | 2370 | | |
| | 0 | 2371 | | return rows; |
| | 0 | 2372 | | } |
| | | 2373 | | |
| | | 2374 | | private async Task<MlAutoApiOrderRow> BuildMlAutoOrderRowAsync(MlAutoOrderSummary summary, CancellationToken ct) |
| | | 2375 | | { |
| | | 2376 | | try |
| | | 2377 | | { |
| | 0 | 2378 | | var items = await _cache.GetOrCreateAsync($"supplier-cart:mlauto-order:{summary.Number}", async entry => |
| | 0 | 2379 | | { |
| | 0 | 2380 | | entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); |
| | 0 | 2381 | | return await _mlAuto.GetOrderItemsAsync(summary.Number, ct); |
| | 0 | 2382 | | }) ?? []; |
| | 0 | 2383 | | return ToMlAutoApiOrderRow(summary, items, null); |
| | | 2384 | | } |
| | 0 | 2385 | | catch (MlAutoApiException ex) |
| | | 2386 | | { |
| | 0 | 2387 | | return ToMlAutoApiOrderRow(summary, [], ex.Message); |
| | | 2388 | | } |
| | 0 | 2389 | | } |
| | | 2390 | | |
| | | 2391 | | private static MlAutoApiOrderRow ToMlAutoApiOrderRow(MlAutoOrderSummary summary, IReadOnlyList<MlAutoOrderItemInfo> |
| | | 2392 | | { |
| | 0 | 2393 | | var deliveryDates = items.Select(x => x.EstimatedDelivery).OfType<DateTime>().ToArray(); |
| | 0 | 2394 | | return new MlAutoApiOrderRow( |
| | 0 | 2395 | | summary.Number, |
| | 0 | 2396 | | summary.CreatedAt, |
| | 0 | 2397 | | DescribeMlAutoOrderStatus(items), |
| | 0 | 2398 | | DescribeMlAutoPayment(items), |
| | 0 | 2399 | | items.Count, |
| | 0 | 2400 | | items.Sum(x => x.Quantity), |
| | 0 | 2401 | | items.Count == 0 ? null : items.Sum(x => x.Sum ?? (x.Price ?? 0m) * x.Quantity), |
| | 0 | 2402 | | deliveryDates.Length == 0 ? null : deliveryDates.Min(), |
| | 0 | 2403 | | deliveryDates.Length == 0 ? null : deliveryDates.Max(), |
| | 0 | 2404 | | items.Select(x => x.OrderComment).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? string.Empty, |
| | 0 | 2405 | | items.Select(x => new MlAutoApiOrderItemRow( |
| | 0 | 2406 | | x.Pin, x.Brand, x.Quantity, x.Price, x.Sum ?? (x.Price ?? 0m) * x.Quantity, |
| | 0 | 2407 | | DescribeMlAutoItemStatus(x), x.StatusCode, x.EstimatedDelivery, |
| | 0 | 2408 | | string.IsNullOrWhiteSpace(x.ItemComment) ? x.OrderComment : x.ItemComment, x.Rejected)).ToArray(), |
| | 0 | 2409 | | detailsError); |
| | | 2410 | | } |
| | | 2411 | | |
| | | 2412 | | // Справочника числовых статусов ML-Auto нет, поэтому состояние выводится из флагов позиции. |
| | | 2413 | | private static string DescribeMlAutoItemStatus(MlAutoOrderItemInfo item) => |
| | 0 | 2414 | | item.Rejected ? "Отказ" : item.Shipped ? "Отгружен" : item.Confirmed ? "Подтверждён" : "В обработке"; |
| | | 2415 | | |
| | | 2416 | | private static string DescribeMlAutoOrderStatus(IReadOnlyList<MlAutoOrderItemInfo> items) |
| | | 2417 | | { |
| | 0 | 2418 | | if (items.Count == 0) return "Нет данных"; |
| | 0 | 2419 | | if (items.All(x => x.Rejected)) return "Отказ"; |
| | 0 | 2420 | | var active = items.Where(x => !x.Rejected).ToArray(); |
| | 0 | 2421 | | if (active.All(x => x.Shipped)) return items.Any(x => x.Rejected) ? "Отгружен частично" : "Отгружен"; |
| | 0 | 2422 | | if (active.Any(x => x.Shipped)) return "Отгружен частично"; |
| | 0 | 2423 | | return active.All(x => x.Confirmed) ? "Подтверждён" : "В обработке"; |
| | | 2424 | | } |
| | | 2425 | | |
| | 0 | 2426 | | private static string DescribeMlAutoPayment(IReadOnlyList<MlAutoOrderItemInfo> items) => items.Count switch |
| | 0 | 2427 | | { |
| | 0 | 2428 | | 0 => "—", |
| | 0 | 2429 | | _ when items.All(x => x.Cashless) => "Безнал", |
| | 0 | 2430 | | _ when items.All(x => !x.Cashless) => "Наличные", |
| | 0 | 2431 | | _ => "Смешанная" |
| | 0 | 2432 | | }; |
| | | 2433 | | |
| | | 2434 | | /// <summary>Плоский список пар «покупатель + адрес» Armtek: шапка заказа задаётся именно ими.</summary> |
| | | 2435 | | private async Task<IReadOnlyList<ArmtekDeliveryOption>> GetArmtekDeliveryOptionsAsync(string providerKey, Cancellati |
| | | 2436 | | { |
| | 0 | 2437 | | var key = $"supplier-cart:armtek-delivery-options:{providerKey}"; |
| | 0 | 2438 | | return await _cache.GetOrCreateAsync(key, async entry => |
| | 0 | 2439 | | { |
| | 0 | 2440 | | entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10); |
| | 0 | 2441 | | var client = providerKey == "ArmtekBy" ? (ArmtekDataManager)_armtekBy : _armtek; |
| | 0 | 2442 | | var userInfo = await client.GetUserInfoAsync(structure: true, ct: ct); |
| | 0 | 2443 | | if (userInfo.HasErrors) |
| | | 2444 | | throw new ArmtekApiException(string.Join("; ", userInfo.Messages.Select(x => x.Text))); |
| | 0 | 2445 | | |
| | 0 | 2446 | | return (userInfo.Resp?.Structure.RgTab ?? []) |
| | | 2447 | | .Where(buyer => !string.IsNullOrWhiteSpace(buyer.Kunnr)) |
| | | 2448 | | .SelectMany(buyer => buyer.ZaTab |
| | | 2449 | | .Where(address => !string.IsNullOrWhiteSpace(address.Kunnr)) |
| | | 2450 | | .Select(address => new ArmtekDeliveryOption( |
| | | 2451 | | buyer.Kunnr, |
| | | 2452 | | $"{buyer.Kunnr} — {FirstNotEmpty(buyer.FullName, buyer.ShortName, "Без названия")}", |
| | | 2453 | | address.Kunnr, |
| | | 2454 | | FirstNotEmpty(address.Address, address.ShortName, address.FullName, address.Kunnr)))) |
| | 0 | 2455 | | .ToArray(); |
| | 0 | 2456 | | }) ?? []; |
| | 0 | 2457 | | } |
| | | 2458 | | |
| | | 2459 | | private static string FirstNotEmpty(params string?[] values) => |
| | 0 | 2460 | | values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? string.Empty; |
| | | 2461 | | |
| | | 2462 | | private async Task<IReadOnlyList<ArmtekApiOrderRow>> GetArmtekApiOrdersAsync(string providerKey, DateOnly createdFro |
| | | 2463 | | { |
| | 0 | 2464 | | var key = $"supplier-cart:armtek-orders:{providerKey}:{createdFrom:yyyyMMdd}:{createdTo:yyyyMMdd}"; |
| | 0 | 2465 | | return await _cache.GetOrCreateAsync(key, async entry => |
| | 0 | 2466 | | { |
| | 0 | 2467 | | entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1); |
| | 0 | 2468 | | var client = providerKey == "ArmtekBy" ? (ArmtekDataManager)_armtekBy : _armtek; |
| | 0 | 2469 | | var userInfo = await client.GetUserInfoAsync(ct: ct); |
| | 0 | 2470 | | if (userInfo.HasErrors) |
| | | 2471 | | throw new ArmtekApiException(string.Join("; ", userInfo.Messages.Select(x => x.Text))); |
| | 0 | 2472 | | |
| | 0 | 2473 | | var buyers = (userInfo.Resp?.Structure.RgTab ?? []) |
| | | 2474 | | .Where(x => !string.IsNullOrWhiteSpace(x.Kunnr)) |
| | | 2475 | | .GroupBy(x => x.Kunnr, StringComparer.Ordinal) |
| | | 2476 | | .Select(group => (Code: group.Key, Name: FirstNotEmpty(group.First().FullName, group.First().ShortName, |
| | 0 | 2477 | | .ToArray(); |
| | 0 | 2478 | | var reports = await Task.WhenAll(buyers.Select(async buyer => |
| | 0 | 2479 | | { |
| | 0 | 2480 | | var response = await client.GetOrderReportByDateAsync(buyer.Code, createdFrom: createdFrom.ToDateTime(Ti |
| | 0 | 2481 | | return (buyer, response); |
| | 0 | 2482 | | })); |
| | 0 | 2483 | | |
| | 0 | 2484 | | return reports |
| | | 2485 | | .Where(x => !x.response.HasErrors) |
| | | 2486 | | .SelectMany(x => (x.response.Resp?.Data ?? []).Select(order => ToArmtekApiOrderRow(x.buyer.Code, x.buyer |
| | | 2487 | | .GroupBy(x => x.OrderNumber, StringComparer.Ordinal) |
| | | 2488 | | .Select(x => x.First()) |
| | | 2489 | | .OrderByDescending(x => x.OrderDate, StringComparer.Ordinal) |
| | | 2490 | | .ThenByDescending(x => x.OrderNumber, StringComparer.Ordinal) |
| | 0 | 2491 | | .ToArray(); |
| | 0 | 2492 | | }) ?? []; |
| | 0 | 2493 | | } |
| | | 2494 | | |
| | 0 | 2495 | | private static ArmtekApiOrderRow ToArmtekApiOrderRow(string buyerCode, string buyerName, ArmtekOrderReportItem order |
| | 0 | 2496 | | buyerCode, buyerName, order.Order, order.OrderDate, order.OrderType, order.OrderStatus, |
| | 0 | 2497 | | decimal.TryParse(order.OrderSum, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.Invaria |
| | 0 | 2498 | | order.Currency, order.DeliveryFrom, order.DeliveryTo, order.IncotermsText, order.Invoice, order.ContractInfo, |
| | 0 | 2499 | | order.CreditStatus, order.MinSumStatus, order.Comment, order.ExpeditionComment); |
| | | 2500 | | |
| | 0 | 2501 | | private static SupplierCartSection BuildArmtekSection(string providerKey, string displayName, string logoUrl, IReadO |
| | 0 | 2502 | | { |
| | 0 | 2503 | | ProviderKey = providerKey, |
| | 0 | 2504 | | DisplayName = displayName, |
| | 0 | 2505 | | LogoUrl = logoUrl, |
| | 0 | 2506 | | CurrencyCode = items.Select(x => x.CurrencyCode).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "RUB", |
| | 0 | 2507 | | ArmtekOrders = orders, |
| | 0 | 2508 | | Rows = items.Select(x => new SupplierCartRow |
| | 0 | 2509 | | { |
| | 0 | 2510 | | LocalCartItemId = x.Id, |
| | 0 | 2511 | | ApiItemId = x.ArtId, ApiCatalogName = x.Brand, ApiItemName = x.ProductName ?? x.Pin, ApiItemNumber = x.Pin, |
| | 0 | 2512 | | ApiPrice = x.SourcePrice, ApiQuantity = x.Quantity, ApiRegion = x.WarehouseName ?? x.Keyzak ?? string.Empty, |
| | 0 | 2513 | | ApiPeriod = x.DeliveryAtUtc.HasValue ? Math.Max(0, (int)Math.Ceiling((x.DeliveryAtUtc.Value - DateTime.UtcNo |
| | 0 | 2514 | | AddedAt = x.CreatedAtUtc.ToLocalTime(), |
| | 0 | 2515 | | ApiSupplierName = x.Status, |
| | 0 | 2516 | | ApiComment = BuildArmtekCartComment(x), |
| | 0 | 2517 | | StudioOrderId = x.StudioOrderId, StudioOrderNumber = x.StudioOrder?.ShipmentNumber, |
| | 0 | 2518 | | StudioProductName = x.StudioOrder?.ProductName, StudioArticle = x.StudioOrder?.Article, |
| | 0 | 2519 | | StudioManufacturer = x.StudioOrder?.Manufacturer?.Name, StudioProductImageUrl = x.StudioOrder?.ProductInfo?. |
| | 0 | 2520 | | StudioClientName = x.StudioOrder?.OzonClient?.Name, |
| | 0 | 2521 | | ArmtekBuyerKunnr = x.BuyerKunnr, ArmtekDeliveryAddressKunnr = x.DeliveryAddressKunnr, |
| | 0 | 2522 | | ArmtekDeliveryAddressName = x.DeliveryAddressName, ArmtekDeliveryMappingLinked = isDeliveryMappingLinked(x) |
| | 0 | 2523 | | }).ToList() |
| | 0 | 2524 | | }; |
| | | 2525 | | |
| | | 2526 | | private static string BuildArmtekCartComment(ArmtekCartItem item) |
| | | 2527 | | { |
| | 0 | 2528 | | if (string.IsNullOrWhiteSpace(item.LastError)) return string.Empty; |
| | 0 | 2529 | | return item.Status == ArmtekCartItemStatus.Failed && !item.LastError.Contains("для Арт.", StringComparison.Ordin |
| | 0 | 2530 | | ? $"{item.LastError} для Арт. {item.Pin} · {item.Brand}." |
| | 0 | 2531 | | : item.LastError; |
| | | 2532 | | } |
| | | 2533 | | |
| | | 2534 | | internal static bool IsAllowedProductImageUrl(string? url) => |
| | 7 | 2535 | | TryCreateProductImageUri(url, out _); |
| | | 2536 | | |
| | | 2537 | | private static bool TryCreateProductImageUri(string? url, out Uri? uri) |
| | | 2538 | | { |
| | 7 | 2539 | | uri = null; |
| | 7 | 2540 | | if (string.IsNullOrWhiteSpace(url) || |
| | 7 | 2541 | | !Uri.TryCreate(url, UriKind.Absolute, out var parsedUri) || |
| | 7 | 2542 | | parsedUri is null || |
| | 7 | 2543 | | (parsedUri.Scheme != Uri.UriSchemeHttp && parsedUri.Scheme != Uri.UriSchemeHttps) || |
| | 7 | 2544 | | !IsAllowedProductImageHost(parsedUri.Host)) |
| | 5 | 2545 | | return false; |
| | | 2546 | | |
| | 2 | 2547 | | uri = parsedUri; |
| | 2 | 2548 | | return true; |
| | | 2549 | | } |
| | | 2550 | | |
| | | 2551 | | private static bool IsAllowedProductImageHost(string host) => |
| | 3 | 2552 | | host.EndsWith(".ozon.ru", StringComparison.OrdinalIgnoreCase) || |
| | 3 | 2553 | | host.EndsWith(".ozone.ru", StringComparison.OrdinalIgnoreCase) || |
| | 3 | 2554 | | host.EndsWith(".ozonusercontent.com", StringComparison.OrdinalIgnoreCase); |
| | | 2555 | | } |