| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | using Microsoft.EntityFrameworkCore; |
| | | 4 | | using Microsoft.Extensions.Logging; |
| | | 5 | | using OzonDomains.Models.ArmtekEtp; |
| | | 6 | | using OzonRepositories.Context; |
| | | 7 | | using Services.ApiServices.ArmtekApi; |
| | | 8 | | using Services.ApiServices.ArmtekApi.Models.Response; |
| | | 9 | | using ArmtekInvoiceItem = OzonDomains.Models.ArmtekEtp.ArmtekInvoiceItem; |
| | | 10 | | using Services.ApiServices.ArmtekEtp; |
| | | 11 | | |
| | | 12 | | namespace Services.ArmtekEtpServices; |
| | | 13 | | |
| | | 14 | | public sealed record ArmtekEtpSyncResult(int Settlements, int NewSettlements, int Invoices, int InvoiceErrors, |
| | | 15 | | string? SettlementsError = null, int InvoicesRemaining = 0, bool SettlementsSkipped = false) |
| | | 16 | | { |
| | | 17 | | public override string ToString() => |
| | | 18 | | $"Фактур по API: {Invoices} (ошибок {InvoiceErrors})" |
| | | 19 | | + (InvoicesRemaining > 0 ? $", ещё {InvoicesRemaining} дочитаются следующими запусками" : string.Empty) |
| | | 20 | | + (SettlementsSkipped ? string.Empty : $"; взаиморасчётов из кабинета: {Settlements} (новых {NewSettlements})") |
| | | 21 | | + (SettlementsError == null ? string.Empty : $"; взаиморасчёты не получены: {SettlementsError}"); |
| | | 22 | | } |
| | | 23 | | |
| | | 24 | | /// <summary>Итог импорта взаиморасчётов из Excel.</summary> |
| | 9 | 25 | | public sealed record ArmtekExcelImportResult(int Rows, int Created, int Updated, DateTime PeriodFrom, DateTime PeriodTo) |
| | | 26 | | { |
| | | 27 | | public override string ToString() => |
| | 1 | 28 | | $"Взаиморасчёты из Excel: {Rows} строк ({Created} новых, {Updated} обновлено) за {PeriodFrom:dd.MM.yyyy}–{Period |
| | | 29 | | } |
| | | 30 | | |
| | | 31 | | /// <summary> |
| | | 32 | | /// Загрузка из Armtek в ArmtekInvoices / ArmtekInvoiceItems / ArmtekSettlements, двумя независимыми шагами: |
| | | 33 | | /// <list type="number"> |
| | | 34 | | /// <item>Фактуры, возвраты и позиции — официальное API ws.armtek.ru: отчёт по заказам за период (номера фактур) |
| | | 35 | | /// и getInvoice по каждой. Без сессий и капчи, поэтому основной источник.</item> |
| | | 36 | | /// <item>Взаиморасчёты и сальдо — личный кабинет etp.armtek.ru (в API таких методов нет). Сбой кабинета (капча, |
| | | 37 | | /// отказ в доступе) не останавливает загрузку фактур и пишется в журнал запусков отдельной ошибкой.</item> |
| | | 38 | | /// </list> |
| | | 39 | | /// Повторный запуск обновляет строки по ключу (Kunnr, Vbeln), позиции фактуры перезаписываются целиком. |
| | | 40 | | /// </summary> |
| | | 41 | | public sealed class ArmtekEtpSyncService |
| | | 42 | | { |
| | | 43 | | /// <summary>Типы документов, у которых в кабинете есть страница фактуры (список из кода сайта).</summary> |
| | | 44 | | public static readonly IReadOnlySet<string> InvoiceDocTypes = new HashSet<string>(StringComparer.Ordinal) |
| | | 45 | | { |
| | | 46 | | "ZROZ", "ZROS", "ZWRE", "ZRED", "ZRW", "ZCRE", "ZF2", "RE", "ZFSE", "ZFW", "ZFB", "ZPLE", "ZPLI", |
| | | 47 | | "ZFKE", "ZFKX", "ZFKW", "ZFWX", "ZSRE" |
| | | 48 | | }; |
| | | 49 | | |
| | | 50 | | private static readonly JsonSerializerOptions JsonOptions = new() |
| | | 51 | | { |
| | | 52 | | Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping |
| | | 53 | | }; |
| | | 54 | | |
| | | 55 | | private readonly ApplicationDbContext _context; |
| | | 56 | | private readonly ArmtekEtpClient _client; |
| | | 57 | | private readonly ArmtekByDataManager _api; |
| | | 58 | | private readonly ArmtekEtpConfig _config; |
| | | 59 | | private readonly ILogger<ArmtekEtpSyncService> _logger; |
| | | 60 | | |
| | | 61 | | public ArmtekEtpSyncService(ApplicationDbContext context, ArmtekEtpClient client, ArmtekByDataManager api, |
| | | 62 | | ArmtekEtpConfig config, ILogger<ArmtekEtpSyncService> logger) |
| | | 63 | | { |
| | | 64 | | _context = context; |
| | | 65 | | _client = client; |
| | | 66 | | _api = api; |
| | | 67 | | _config = config; |
| | | 68 | | _logger = logger; |
| | | 69 | | } |
| | | 70 | | |
| | | 71 | | public const string ScheduleTrigger = "Расписание"; |
| | | 72 | | |
| | | 73 | | /// <summary> |
| | | 74 | | /// Загрузка за период (по умолчанию — последние LookbackDays дней). Каждый запуск пишется в ArmtekEtpSyncRuns: |
| | | 75 | | /// по нему страница показывает «Обновлено» и последнюю ошибку. |
| | | 76 | | /// </summary> |
| | | 77 | | public async Task<ArmtekEtpSyncResult> SyncAsync(DateOnly? from = null, DateOnly? to = null, CancellationToken ct = |
| | | 78 | | string trigger = ScheduleTrigger, bool includeSettlements = true) |
| | | 79 | | { |
| | | 80 | | var today = DateOnly.FromDateTime(DateTime.Today); |
| | | 81 | | var periodTo = to ?? today; |
| | | 82 | | var periodFrom = from ?? periodTo.AddDays(-Math.Max(1, _config.LookbackDays)); |
| | | 83 | | |
| | | 84 | | var run = new ArmtekEtpSyncRun |
| | | 85 | | { |
| | | 86 | | StartedAtUtc = DateTime.UtcNow, |
| | | 87 | | PeriodFrom = periodFrom.ToDateTime(TimeOnly.MinValue), |
| | | 88 | | PeriodTo = periodTo.ToDateTime(TimeOnly.MinValue), |
| | | 89 | | Trigger = Cut(trigger, 256) ?? ScheduleTrigger |
| | | 90 | | }; |
| | | 91 | | _context.ArmtekEtpSyncRuns.Add(run); |
| | | 92 | | await _context.SaveChangesAsync(ct); |
| | | 93 | | var runId = run.Id; |
| | | 94 | | |
| | | 95 | | try |
| | | 96 | | { |
| | | 97 | | var result = await RunAsync(periodFrom, periodTo, includeSettlements, ct); |
| | | 98 | | await FinishRunAsync(runId, true, result.ToString(), result); |
| | | 99 | | return result; |
| | | 100 | | } |
| | | 101 | | catch (ArmtekSyncPartialException partial) |
| | | 102 | | { |
| | | 103 | | await FinishRunAsync(runId, false, partial.Message, partial.Result); |
| | | 104 | | throw; |
| | | 105 | | } |
| | | 106 | | catch (Exception ex) |
| | | 107 | | { |
| | | 108 | | await FinishRunAsync(runId, false, ex.Message, null); |
| | | 109 | | throw; |
| | | 110 | | } |
| | | 111 | | } |
| | | 112 | | |
| | | 113 | | /// <summary>Итог запуска пишется отдельно от основной работы: даже после сбоя и при отменённом токене.</summary> |
| | | 114 | | private async Task FinishRunAsync(long runId, bool success, string message, ArmtekEtpSyncResult? result) |
| | | 115 | | { |
| | | 116 | | try |
| | | 117 | | { |
| | | 118 | | _context.ChangeTracker.Clear(); |
| | | 119 | | var run = await _context.ArmtekEtpSyncRuns.FindAsync(runId); |
| | | 120 | | if (run == null) return; |
| | | 121 | | run.FinishedAtUtc = DateTime.UtcNow; |
| | | 122 | | run.Success = success; |
| | | 123 | | run.Message = Cut(message, 2000); |
| | | 124 | | if (result != null) |
| | | 125 | | { |
| | | 126 | | run.Settlements = result.Settlements; |
| | | 127 | | run.NewSettlements = result.NewSettlements; |
| | | 128 | | run.Invoices = result.Invoices; |
| | | 129 | | run.InvoiceErrors = result.InvoiceErrors; |
| | | 130 | | run.SettlementsError = Cut(result.SettlementsError, 2000); |
| | | 131 | | } |
| | | 132 | | await _context.SaveChangesAsync(); |
| | | 133 | | } |
| | | 134 | | catch (Exception ex) |
| | | 135 | | { |
| | | 136 | | _logger.LogWarning(ex, "Armtek ETP: не удалось записать итог запуска {RunId}", runId); |
| | | 137 | | } |
| | | 138 | | } |
| | | 139 | | |
| | | 140 | | private async Task<ArmtekEtpSyncResult> RunAsync(DateOnly periodFrom, DateOnly periodTo, bool includeSettlements, Ca |
| | | 141 | | { |
| | | 142 | | // Сначала взаиморасчёты: фактуры по их документам дочитываются этим же запуском (связь документ ↔ фактура). |
| | | 143 | | string? settlementsError = null; |
| | | 144 | | (int Count, int Created) settlements = (0, 0); |
| | | 145 | | if (includeSettlements) |
| | | 146 | | try |
| | | 147 | | { |
| | | 148 | | await _client.LoginAsync(ct); |
| | | 149 | | var rows = await _client.GetSettlementsAsync(periodFrom, periodTo, ct); |
| | | 150 | | var (saved, created) = await UpsertSettlementsAsync(rows, ct); |
| | | 151 | | settlements = (saved.Count, created); |
| | | 152 | | } |
| | | 153 | | catch (Exception ex) when (ex is not OperationCanceledException) |
| | | 154 | | { |
| | | 155 | | settlementsError = ex.Message; |
| | | 156 | | _logger.LogWarning(ex, "Armtek ETP: взаиморасчёты за {From}–{To} не получены", periodFrom, periodTo); |
| | | 157 | | } |
| | | 158 | | |
| | | 159 | | Exception? apiError = null; |
| | | 160 | | (int Loaded, int Errors, int Remaining) invoices = (0, 0, 0); |
| | | 161 | | try |
| | | 162 | | { |
| | | 163 | | invoices = await LoadInvoicesFromApiAsync(periodFrom, periodTo, ct); |
| | | 164 | | } |
| | | 165 | | catch (Exception ex) when (ex is not OperationCanceledException) |
| | | 166 | | { |
| | | 167 | | apiError = ex; |
| | | 168 | | _logger.LogWarning(ex, "Armtek API: фактуры за {From}–{To} не загружены", periodFrom, periodTo); |
| | | 169 | | } |
| | | 170 | | |
| | | 171 | | var result = new ArmtekEtpSyncResult(settlements.Count, settlements.Created, invoices.Loaded, invoices.Errors, s |
| | | 172 | | invoices.Remaining, SettlementsSkipped: !includeSettlements); |
| | | 173 | | _logger.LogInformation("Armtek {From:dd.MM.yyyy}–{To:dd.MM.yyyy}: {Result}", periodFrom.ToDateTime(TimeOnly.MinV |
| | | 174 | | periodTo.ToDateTime(TimeOnly.MinValue), result); |
| | | 175 | | if (apiError != null) |
| | | 176 | | throw new ArmtekSyncPartialException("Фактуры по API не загружены: " + apiError.Message |
| | | 177 | | + (settlementsError == null ? $"; взаиморасчётов из кабинета: {settlements.Count}" : "; взаиморасчёты то |
| | | 178 | | result, apiError); |
| | | 179 | | return result; |
| | | 180 | | } |
| | | 181 | | |
| | | 182 | | /// <summary> |
| | | 183 | | /// Фактуры за период через API: getOrderReportByDate даёт заказы с номерами фактур (продажи и возвраты), по каждой |
| | | 184 | | /// новой или недавней фактуре — getInvoice. Названия партнёров (покупатель, пункт разгрузки, адрес, контакт) API |
| | | 185 | | /// отдаёт кодами — они расшифровываются одним getUserInfo. |
| | | 186 | | /// </summary> |
| | | 187 | | private async Task<(int Loaded, int Errors, int Remaining)> LoadInvoicesFromApiAsync(DateOnly periodFrom, DateOnly p |
| | | 188 | | { |
| | | 189 | | var kunrg = await ResolvePayerAsync(ct); |
| | | 190 | | // Отчёт фильтрует по дате создания заказа, а фактура выставляется позже (дальние склады — недели спустя): |
| | | 191 | | // без запаса назад фактура периода с заказом, созданным до его начала, не попадает в отчёт. |
| | | 192 | | var reportFrom = periodFrom.AddDays(-Math.Max(0, _config.OrderReportExtraDays)); |
| | | 193 | | var report = await _api.GetOrderReportByDateAsync(kunrg, |
| | | 194 | | createdFrom: reportFrom.ToDateTime(TimeOnly.MinValue), createdTo: periodTo.ToDateTime(TimeOnly.MinValue), ct |
| | | 195 | | if (report.HasErrors) |
| | | 196 | | throw new ArmtekEtpException("Armtek API, отчёт по заказам: " + string.Join("; ", report.Messages.Select(m = |
| | | 197 | | |
| | | 198 | | var ordersByInvoice = (report.Resp?.Data ?? []) |
| | | 199 | | .Where(o => !string.IsNullOrWhiteSpace(o.Invoice)) |
| | | 200 | | .GroupBy(o => o.Invoice.Trim()) |
| | | 201 | | .ToDictionary(g => g.Key, g => g.ToList()); |
| | | 202 | | |
| | | 203 | | // Связь со взаиморасчётами: каждая фактура и кредит возврата из них (загруженных из кабинета или из Excel) |
| | | 204 | | // дочитывается через API, даже если её заказа нет в отчёте (заказ создан задолго до периода). |
| | | 205 | | var fromDate = periodFrom.ToDateTime(TimeOnly.MinValue); |
| | | 206 | | var toDate = periodTo.ToDateTime(TimeOnly.MinValue); |
| | | 207 | | var settlementDocs = await _context.ArmtekSettlements.AsNoTracking() |
| | | 208 | | .Where(x => x.Kunnr == kunrg && x.DocDate >= fromDate && x.DocDate <= toDate) |
| | | 209 | | .Select(x => new { x.Vbeln, x.DocType, x.DocTypeName, x.DocDate }) |
| | | 210 | | .ToListAsync(ct); |
| | | 211 | | var docDates = new Dictionary<string, DateTime?>(); |
| | | 212 | | foreach (var d in settlementDocs.Where(d => IsInvoiceDocument(d.DocType, d.DocTypeName))) |
| | | 213 | | docDates[d.Vbeln] = d.DocDate; |
| | | 214 | | |
| | | 215 | | var orders = ordersByInvoice.Keys.Union(docDates.Keys) |
| | | 216 | | .Select(v => (Vbeln: v, Orders: ordersByInvoice.TryGetValue(v, out var o) ? o : new List<ArmtekOrderReportIt |
| | | 217 | | Date: docDates.TryGetValue(v, out var dd) ? dd : null)) |
| | | 218 | | .ToList(); |
| | | 219 | | |
| | | 220 | | var refreshFrom = DateTime.Today.AddDays(-Math.Max(0, _config.InvoiceRefreshDays)); |
| | | 221 | | var vbelns = orders.Select(g => g.Vbeln).ToList(); |
| | | 222 | | var known = await _context.ArmtekInvoices.AsNoTracking() |
| | | 223 | | .Where(x => x.Kunnr == kunrg && vbelns.Contains(x.Vbeln)) |
| | | 224 | | .Select(x => new { x.Vbeln, x.Source, x.InvoiceDate }) |
| | | 225 | | .ToListAsync(ct); |
| | | 226 | | var knownByVbeln = known.ToDictionary(x => x.Vbeln); |
| | | 227 | | |
| | | 228 | | // Новые, ещё не прочитанные через API (страницы кабинета дополняются API-полями) и свежие — у них меняются возв |
| | | 229 | | var pending = orders.Where(g => !knownByVbeln.TryGetValue(g.Vbeln, out var k) |
| | | 230 | | || k.Source != ApiSource || k.InvoiceDate == null || k.InvoiceDate >= refreshFrom) |
| | | 231 | | .OrderByDescending(g => g.Date ?? DateTime.MaxValue).ThenByDescending(g => g.Vbeln) |
| | | 232 | | .ToList(); |
| | | 233 | | if (pending.Count == 0) |
| | | 234 | | return (0, 0, 0); |
| | | 235 | | |
| | | 236 | | // У API суточная квота на логин (её же тратит проценка): за запуск — не больше InvoiceLoadLimit фактур, |
| | | 237 | | // самые новые первыми; остальные дочитают следующие запуски. |
| | | 238 | | var limit = Math.Max(1, _config.InvoiceLoadLimit); |
| | | 239 | | var toLoad = pending.Take(limit).ToList(); |
| | | 240 | | |
| | | 241 | | var parties = await LoadPartiesAsync(kunrg, ct); |
| | | 242 | | int loaded = 0, errors = 0; |
| | | 243 | | foreach (var group in toLoad) |
| | | 244 | | { |
| | | 245 | | ct.ThrowIfCancellationRequested(); |
| | | 246 | | try |
| | | 247 | | { |
| | | 248 | | var response = await _api.GetInvoiceAsync(kunrg, group.Vbeln, ct: ct); |
| | | 249 | | if (response.Messages.Any(m => ArmtekDailyQuotaGuard.IsExceeded(m.Text))) |
| | | 250 | | { |
| | | 251 | | _logger.LogWarning("Armtek API: суточная квота исчерпана, фактуры дочитаются следующим запуском"); |
| | | 252 | | break; |
| | | 253 | | } |
| | | 254 | | if (response.HasErrors || response.Resp == null) |
| | | 255 | | throw new ArmtekEtpException(string.Join("; ", response.Messages.Select(m => m.Text))); |
| | | 256 | | await SaveApiInvoiceAsync(kunrg, group.Vbeln, response.Resp, group.Orders, parties, ct); |
| | | 257 | | loaded++; |
| | | 258 | | } |
| | | 259 | | catch (Exception ex) when (ex is not OperationCanceledException) |
| | | 260 | | { |
| | | 261 | | errors++; |
| | | 262 | | _logger.LogWarning(ex, "Armtek API: фактура {Kunrg}/{Invoice} не загружена", kunrg, group.Vbeln); |
| | | 263 | | } |
| | | 264 | | } |
| | | 265 | | return (loaded, errors, pending.Count - loaded - errors); |
| | | 266 | | } |
| | | 267 | | |
| | | 268 | | /// <summary>Плательщик из настроек, иначе — плательщик по умолчанию из getUserInfo.</summary> |
| | | 269 | | private async Task<string> ResolvePayerAsync(CancellationToken ct) |
| | | 270 | | { |
| | | 271 | | if (!string.IsNullOrWhiteSpace(_config.KunnrRg)) |
| | | 272 | | return _config.KunnrRg.Trim(); |
| | | 273 | | var info = await _api.GetUserInfoAsync(ct: ct); |
| | | 274 | | var rg = info.Resp?.Structure.RgTab.OrderByDescending(x => x.Default).FirstOrDefault()?.Kunnr; |
| | | 275 | | return !string.IsNullOrWhiteSpace(rg) |
| | | 276 | | ? rg.Trim() |
| | | 277 | | : throw new ArmtekEtpException("Armtek API: не задан плательщик (ArmtekEtp:KunnrRg) и getUserInfo его не вер |
| | | 278 | | } |
| | | 279 | | |
| | | 280 | | private async Task<ArmtekBuyerItem?> LoadPartiesAsync(string kunrg, CancellationToken ct) |
| | | 281 | | { |
| | | 282 | | try |
| | | 283 | | { |
| | | 284 | | var info = await _api.GetUserInfoAsync(ct: ct); |
| | | 285 | | return info.Resp?.Structure.RgTab.FirstOrDefault(x => SameCode(x.Kunnr, kunrg)); |
| | | 286 | | } |
| | | 287 | | catch (Exception ex) when (ex is not OperationCanceledException) |
| | | 288 | | { |
| | | 289 | | _logger.LogWarning(ex, "Armtek API: getUserInfo не получен — партнёры фактур останутся кодами"); |
| | | 290 | | return null; |
| | | 291 | | } |
| | | 292 | | } |
| | | 293 | | |
| | | 294 | | /// <summary>Загрузка (перечитывание) одной фактуры по запросу со страницы — через API.</summary> |
| | | 295 | | public async Task LoadInvoiceAsync(string kunnr, string vbeln, CancellationToken ct = default) |
| | | 296 | | { |
| | | 297 | | var response = await _api.GetInvoiceAsync(kunnr, vbeln, ct: ct); |
| | | 298 | | if (response.HasErrors || response.Resp == null) |
| | | 299 | | throw new ArmtekEtpException("Armtek API: " + string.Join("; ", response.Messages.Select(m => m.Text))); |
| | | 300 | | |
| | | 301 | | // Заказ и причина возврата — из отчёта по заказам вокруг даты фактуры. |
| | | 302 | | var date = ParseDate(response.Resp.Header.InvoiceDate, "yyyyMMdd") ?? DateTime.Today; |
| | | 303 | | var report = await _api.GetOrderReportByDateAsync(kunnr, createdFrom: date.AddDays(-60), createdTo: date, ct: ct |
| | | 304 | | var orders = (report.Resp?.Data ?? []).Where(o => o.Invoice.Trim() == vbeln).ToList(); |
| | | 305 | | await SaveApiInvoiceAsync(kunnr, vbeln, response.Resp, orders, await LoadPartiesAsync(kunnr, ct), ct); |
| | | 306 | | } |
| | | 307 | | |
| | | 308 | | public const string ApiSource = "api"; |
| | | 309 | | |
| | | 310 | | private async Task SaveApiInvoiceAsync(string kunrg, string vbeln, ArmtekGetInvoiceResponse resp, |
| | | 311 | | IReadOnlyList<ArmtekOrderReportItem> orders, ArmtekBuyerItem? parties, CancellationToken ct) |
| | | 312 | | { |
| | | 313 | | var invoice = await _context.ArmtekInvoices |
| | | 314 | | .Include(x => x.Items) |
| | | 315 | | .SingleOrDefaultAsync(x => x.Kunnr == kunrg && x.Vbeln == vbeln, ct); |
| | | 316 | | if (invoice == null) |
| | | 317 | | { |
| | | 318 | | invoice = new ArmtekInvoice { Kunnr = kunrg, Vbeln = vbeln }; |
| | | 319 | | _context.ArmtekInvoices.Add(invoice); |
| | | 320 | | } |
| | | 321 | | |
| | | 322 | | // Поля, которых нет в API («Создал», «Статус доставки (ММВ)», товарная группа), остаются от страницы кабинета. |
| | | 323 | | var etpNames = invoice.Items.Where(x => x.ArmtekCode != null) |
| | | 324 | | .GroupBy(x => (x.Position, x.ArmtekCode)) |
| | | 325 | | .ToDictionary(g => g.Key, g => g.First().Name); |
| | | 326 | | _context.ArmtekInvoiceItems.RemoveRange(invoice.Items); |
| | | 327 | | invoice.Items.Clear(); |
| | | 328 | | |
| | | 329 | | MapApiInvoice(invoice, resp, orders, parties, etpNames); |
| | | 330 | | invoice.LoadedAtUtc = DateTime.UtcNow; |
| | | 331 | | await _context.SaveChangesAsync(ct); |
| | | 332 | | _context.ChangeTracker.Clear(); |
| | | 333 | | } |
| | | 334 | | |
| | | 335 | | internal static void MapApiInvoice(ArmtekInvoice inv, ArmtekGetInvoiceResponse resp, IReadOnlyList<ArmtekOrderReport |
| | | 336 | | ArmtekBuyerItem? parties, IReadOnlyDictionary<(int, string?), string?>? etpNames = null) |
| | | 337 | | { |
| | | 338 | | var h = resp.Header; |
| | | 339 | | inv.Source = ApiSource; |
| | | 340 | | inv.InvoiceDate = ParseDate(h.InvoiceDate, "yyyyMMdd"); |
| | | 341 | | inv.CreatedAt = ParseDate(h.CreatedAt, "yyyyMMddHHmmss"); |
| | | 342 | | inv.BuyerCode = Cut(inv.Kunnr, 20); |
| | | 343 | | inv.BuyerName = Cut(parties?.ShortName is { Length: > 0 } sn ? sn : parties?.FullName, 500) ?? inv.BuyerName; |
| | | 344 | | inv.UnloadingPointCode = Cut(NullIfEmpty(h.Kunwe), 20); |
| | | 345 | | inv.UnloadingPointName = Cut(PartyName(parties?.WeTab, h.Kunwe), 500) ?? inv.UnloadingPointName; |
| | | 346 | | inv.DeliveryAddressCode = Cut(NullIfEmpty(h.Kunza), 20); |
| | | 347 | | inv.DeliveryAddress = Cut(PartyAddress(parties?.ZaTab, h.Kunza), 500) ?? inv.DeliveryAddress; |
| | | 348 | | inv.ContactCode = Cut(NullIfEmpty(h.ContactCode), 20); |
| | | 349 | | inv.Contact = Cut(ContactName(parties?.ContactTab, h.ContactCode), 500) ?? inv.Contact; |
| | | 350 | | inv.ReceiveMethod = Cut(NullIfEmpty(h.IncotermsText), 100); |
| | | 351 | | inv.UpdNumber = Cut(NullIfEmpty(h.Upd), 50); |
| | | 352 | | inv.TtnNumber = Cut(NullIfEmpty(h.Torg12), 50); |
| | | 353 | | inv.Payment = Cut(NullIfEmpty(h.Payment), 50); |
| | | 354 | | inv.PickupPointName = Cut(NullIfEmpty(h.PickupPointName), 255); |
| | | 355 | | inv.Contract = Cut(NullIfEmpty(h.ContractInfo), 100); |
| | | 356 | | inv.Currency = Cut(NullIfEmpty(h.Currency), 5); |
| | | 357 | | // MAX_RET_SUM без REFLAG=1 приходит одинаковым для всех фактур (не сумма этой фактуры) — не сохраняем. |
| | | 358 | | inv.MaxReturnSum = null; |
| | | 359 | | inv.OrderNumber = Cut(NullIfEmpty(string.Join(", ", orders.Select(o => o.Order.TrimStart('0')).Where(o => o.Leng |
| | | 360 | | inv.OrderType = Cut(NullIfEmpty(string.Join(", ", orders.Select(o => o.OrderType).Where(o => o.Length > 0).Disti |
| | | 361 | | inv.OrderStatus = Cut(NullIfEmpty(string.Join(", ", orders.Select(o => o.OrderStatus).Where(o => o.Length > 0).D |
| | | 362 | | // COMMENT заказа — причина только у возвратов; у продаж там наш комментарий (метка «Studio2 IDs»). |
| | | 363 | | inv.ReturnReason = Cut(NullIfEmpty(string.Join("; ", orders |
| | | 364 | | .Where(o => o.OrderType.Contains("Возврат", StringComparison.OrdinalIgnoreCase)) |
| | | 365 | | .Select(o => o.Comment).Where(o => o.Length > 0).Distinct())), 500); |
| | | 366 | | |
| | | 367 | | foreach (var r in resp.Items) |
| | | 368 | | { |
| | | 369 | | var position = int.TryParse(r.Posnr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p) ? p : 0; |
| | | 370 | | var etpName = etpNames != null && etpNames.TryGetValue((position, NullIfEmpty(r.Matnr)), out var n) ? n : nu |
| | | 371 | | inv.Items.Add(new ArmtekInvoiceItem |
| | | 372 | | { |
| | | 373 | | Position = position, |
| | | 374 | | ArmtekCode = Cut(NullIfEmpty(r.Matnr), 50), |
| | | 375 | | Article = Cut(NullIfEmpty(r.Pin), 100), |
| | | 376 | | Brand = Cut(NullIfEmpty(r.Brand), 100), |
| | | 377 | | Name = Cut(etpName ?? NullIfEmpty(r.Name), 500), |
| | | 378 | | FullName = Cut(NullIfEmpty(r.Name), 1000), |
| | | 379 | | Price = Dec(r.Price) ?? 0, |
| | | 380 | | Quantity = Dec(r.Amount) ?? 0, |
| | | 381 | | Returned = Dec(r.Retmg) ?? 0, |
| | | 382 | | Amount = Dec(r.Summa) ?? 0, |
| | | 383 | | OrderNumber = Cut(NullIfEmpty(r.Order.TrimStart('0')), 20), |
| | | 384 | | OrderPosition = Cut(NullIfEmpty(r.OrderPosition), 10), |
| | | 385 | | Note = Cut(NullIfEmpty(r.OrderNote), 500), |
| | | 386 | | Gtd = Cut(NullIfEmpty(r.Gtd), 100), |
| | | 387 | | Country = Cut(NullIfEmpty(r.Country), 100), |
| | | 388 | | NoReturn = r.NoReturn == "X", |
| | | 389 | | ReturnDays = int.TryParse(r.ReturnDays, NumberStyles.Integer, CultureInfo.InvariantCulture, out var d) ? |
| | | 390 | | MaxReturnDate = inv.InvoiceDate is { } invDate && int.TryParse(r.ReturnDays, out var days) && days > 0 ? |
| | | 391 | | Weight = Dec(r.Weight), |
| | | 392 | | Volume = Dec(r.Volume), |
| | | 393 | | VatRate = Dec(r.VatRate), |
| | | 394 | | VatAmount = Dec(r.VatAmount), |
| | | 395 | | Ean = Cut(NullIfEmpty(r.Ean), 50), |
| | | 396 | | Tnved = Cut(NullIfEmpty(r.Tnved), 20), |
| | | 397 | | RawJson = Newtonsoft.Json.JsonConvert.SerializeObject(r) |
| | | 398 | | }); |
| | | 399 | | } |
| | | 400 | | |
| | | 401 | | inv.TotalAmount = Dec(h.Summa) ?? inv.Items.Sum(x => x.Amount); |
| | | 402 | | inv.TotalQuantity = inv.Items.Sum(x => x.Quantity); |
| | | 403 | | inv.ReturnedQuantity = inv.Items.Sum(x => x.Returned); |
| | | 404 | | inv.HeaderJson = JsonSerializer.Serialize(BuildApiHeader(inv), JsonOptions); |
| | | 405 | | inv.ColumnsJson = "[]"; |
| | | 406 | | inv.ItemsJson = Newtonsoft.Json.JsonConvert.SerializeObject(new { header = h, items = resp.Items, orders }); |
| | | 407 | | } |
| | | 408 | | |
| | | 409 | | /// <summary>Строки шапки для страницы фактуры — в том же виде, что и у страниц кабинета («название / код / значение |
| | | 410 | | private static List<ArmtekEtpHeaderField> BuildApiHeader(ArmtekInvoice inv) |
| | | 411 | | { |
| | | 412 | | var rows = new List<ArmtekEtpHeaderField>(); |
| | | 413 | | void Add(string label, string? value, string? code = null) |
| | | 414 | | { |
| | | 415 | | if (string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(code)) return; |
| | | 416 | | var text = string.IsNullOrWhiteSpace(code) ? value! : (code + " " + value).Trim(); |
| | | 417 | | rows.Add(new ArmtekEtpHeaderField(label, NullIfEmpty(code), text, null)); |
| | | 418 | | } |
| | | 419 | | Add("Дата фактуры", inv.InvoiceDate?.ToString("dd.MM.yyyy")); |
| | | 420 | | Add("Дата и время создания", inv.CreatedAt?.ToString("dd.MM.yyyy HH:mm:ss")); |
| | | 421 | | Add("Создал", inv.CreatedBy); |
| | | 422 | | Add("Заказ", inv.OrderNumber); |
| | | 423 | | Add("Тип заказа", inv.OrderType); |
| | | 424 | | Add("Статус заказа", inv.OrderStatus); |
| | | 425 | | Add("Причина возврата", inv.ReturnReason); |
| | | 426 | | Add("Покупатель", inv.BuyerName, inv.BuyerCode); |
| | | 427 | | Add("Пункт разгрузки", inv.UnloadingPointName, inv.UnloadingPointCode); |
| | | 428 | | Add("Адрес доставки", inv.DeliveryAddress, inv.DeliveryAddressCode); |
| | | 429 | | Add("Контактное лицо", inv.Contact, inv.ContactCode); |
| | | 430 | | Add("Способ получения", inv.ReceiveMethod); |
| | | 431 | | Add("Пункт выдачи", inv.PickupPointName); |
| | | 432 | | Add("Статус доставки (ММВ)", inv.DeliveryStatus); |
| | | 433 | | Add("Оплата", inv.Payment); |
| | | 434 | | Add("Договор", inv.Contract); |
| | | 435 | | Add("УПД/Товчек", inv.UpdNumber); |
| | | 436 | | Add("ТН/ТТН", inv.TtnNumber); |
| | | 437 | | Add("Можно вернуть на сумму", inv.MaxReturnSum?.ToString("N2", CultureInfo.GetCultureInfo("ru-RU")) is { } m ? m |
| | | 438 | | return rows; |
| | | 439 | | } |
| | | 440 | | |
| | | 441 | | private static string? PartyName(IEnumerable<ArmtekPartyItem>? list, string? code) |
| | | 442 | | { |
| | | 443 | | var p = list?.FirstOrDefault(x => SameCode(x.Kunnr, code)); |
| | | 444 | | return p == null ? null : NullIfEmpty(p.FullName) ?? NullIfEmpty(p.ShortName); |
| | | 445 | | } |
| | | 446 | | |
| | | 447 | | private static string? PartyAddress(IEnumerable<ArmtekPartyItem>? list, string? code) |
| | | 448 | | { |
| | | 449 | | var p = list?.FirstOrDefault(x => SameCode(x.Kunnr, code)); |
| | | 450 | | return p == null ? null : NullIfEmpty(p.Address) ?? NullIfEmpty(p.FullName) ?? NullIfEmpty(p.ShortName); |
| | | 451 | | } |
| | | 452 | | |
| | | 453 | | private static string? ContactName(IEnumerable<ArmtekContactItem>? list, string? code) |
| | | 454 | | { |
| | | 455 | | var c = list?.FirstOrDefault(x => SameCode(x.Parnr, code)); |
| | | 456 | | return c == null ? null : NullIfEmpty(string.Join(" ", new[] { c.LastName, c.FirstName, c.MiddleName, c.Phone }. |
| | | 457 | | } |
| | | 458 | | |
| | | 459 | | /// <summary>Коды SAP приходят то с ведущими нулями (KUNWE "0043174495"), то без (KUNNR в getUserInfo).</summary> |
| | | 460 | | private static bool SameCode(string? a, string? b) => |
| | | 461 | | !string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b) && a.Trim().TrimStart('0') == b.Trim().TrimStart( |
| | | 462 | | |
| | | 463 | | private static decimal? Dec(string? s) => |
| | | 464 | | decimal.TryParse(s?.Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out var d) ? d : null; |
| | | 465 | | |
| | | 466 | | private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim(); |
| | | 467 | | |
| | | 468 | | /// <summary> |
| | | 469 | | /// Импорт строк файла «Взаиморасчёты» (уже проверенных <see cref="ArmtekSettlementsExcelReader"/>): upsert по |
| | | 470 | | /// (Kunnr, Vbeln) и запись в журнал запусков — для «Обновлено» это такая же загрузка взаиморасчётов, как из кабинет |
| | | 471 | | /// Поля, которых в файле нет (код вида документа, направление, балансовая единица), у существующих строк сохраняютс |
| | | 472 | | /// </summary> |
| | | 473 | | public async Task<ArmtekExcelImportResult> ImportSettlementsAsync(IReadOnlyList<ArmtekSettlementExcelRow> rows, stri |
| | | 474 | | string? user, CancellationToken ct = default) |
| | | 475 | | { |
| | | 476 | | if (rows.Count == 0) |
| | | 477 | | throw new ArgumentException("Нет строк для импорта.", nameof(rows)); |
| | | 478 | | |
| | | 479 | | var now = DateTime.UtcNow; |
| | | 480 | | var run = new ArmtekEtpSyncRun |
| | | 481 | | { |
| | | 482 | | StartedAtUtc = now, |
| | | 483 | | PeriodFrom = rows.Min(r => r.DocDate), |
| | | 484 | | PeriodTo = rows.Max(r => r.DocDate), |
| | | 485 | | Trigger = Cut($"Excel «{fileName}»{(string.IsNullOrWhiteSpace(user) ? "" : " — " + user)}", 256)! |
| | | 486 | | }; |
| | | 487 | | _context.ArmtekEtpSyncRuns.Add(run); |
| | | 488 | | await _context.SaveChangesAsync(ct); |
| | | 489 | | |
| | | 490 | | var vbelns = rows.Select(r => r.Vbeln).Distinct().ToList(); |
| | | 491 | | var existing = (await _context.ArmtekSettlements.Where(x => vbelns.Contains(x.Vbeln)).ToListAsync(ct)) |
| | | 492 | | .ToDictionary(x => (x.Kunnr, x.Vbeln)); |
| | | 493 | | int created = 0, updated = 0; |
| | | 494 | | foreach (var r in rows) |
| | | 495 | | { |
| | | 496 | | if (!existing.TryGetValue((r.Kunnr, r.Vbeln), out var e)) |
| | | 497 | | { |
| | | 498 | | e = new ArmtekSettlement { Kunnr = r.Kunnr, Vbeln = r.Vbeln, FirstSeenAtUtc = now }; |
| | | 499 | | _context.ArmtekSettlements.Add(e); |
| | | 500 | | existing[(r.Kunnr, r.Vbeln)] = e; |
| | | 501 | | created++; |
| | | 502 | | } |
| | | 503 | | else |
| | | 504 | | { |
| | | 505 | | updated++; |
| | | 506 | | } |
| | | 507 | | |
| | | 508 | | e.ClientName = Cut(r.ClientName, 255) ?? e.ClientName; |
| | | 509 | | e.DocDate = r.DocDate; |
| | | 510 | | e.DocTypeName = Cut(r.DocTypeName, 255); |
| | | 511 | | e.DocType ??= DocTypeByName(r.DocTypeName); |
| | | 512 | | e.PrintNumber = Cut(r.DocNumber, 50) ?? e.PrintNumber; |
| | | 513 | | e.DocNumber ??= Cut(r.DocNumber, 50); |
| | | 514 | | e.PaymentMethodName = Cut(r.PaymentMethodName, 255) ?? e.PaymentMethodName; |
| | | 515 | | e.Currency = Cut(r.Currency, 5) ?? e.Currency; |
| | | 516 | | e.Amount = r.Amount; |
| | | 517 | | e.ReturnAmount = r.ReturnAmount; |
| | | 518 | | e.PaymentAmount = r.PaymentAmount; |
| | | 519 | | e.Balance = r.Balance; |
| | | 520 | | e.PayUntil = r.PayUntil ?? e.PayUntil; |
| | | 521 | | e.Contract = Cut(r.Contract, 100) ?? e.Contract; |
| | | 522 | | e.RawJson = JsonSerializer.Serialize(new { source = "excel", file = fileName, row = r.RowNumber, values = r. |
| | | 523 | | e.UpdatedAtUtc = now; |
| | | 524 | | } |
| | | 525 | | await _context.SaveChangesAsync(ct); |
| | | 526 | | |
| | | 527 | | var result = new ArmtekExcelImportResult(rows.Count, created, updated, run.PeriodFrom, run.PeriodTo); |
| | | 528 | | _context.ChangeTracker.Clear(); |
| | | 529 | | var saved = await _context.ArmtekEtpSyncRuns.FindAsync([run.Id], ct); |
| | | 530 | | if (saved != null) |
| | | 531 | | { |
| | | 532 | | saved.FinishedAtUtc = DateTime.UtcNow; |
| | | 533 | | saved.Success = true; |
| | | 534 | | saved.Message = Cut(result.ToString(), 2000); |
| | | 535 | | saved.Settlements = rows.Count; |
| | | 536 | | saved.NewSettlements = created; |
| | | 537 | | await _context.SaveChangesAsync(ct); |
| | | 538 | | } |
| | | 539 | | _logger.LogInformation("Armtek: {Result}", result); |
| | | 540 | | return result; |
| | | 541 | | } |
| | | 542 | | |
| | | 543 | | /// <summary>Код вида документа по названию из Excel (в файле только название): фактура — ZFW, кредит возврата — RE. |
| | | 544 | | internal static string? DocTypeByName(string? name) |
| | | 545 | | { |
| | | 546 | | if (string.IsNullOrWhiteSpace(name) || name.Contains("Сторно", StringComparison.OrdinalIgnoreCase)) |
| | | 547 | | return null; |
| | | 548 | | if (name.Contains("возврат", StringComparison.OrdinalIgnoreCase)) return "RE"; |
| | | 549 | | if (name.Contains("фактур", StringComparison.OrdinalIgnoreCase)) return "ZFW"; |
| | | 550 | | return null; |
| | | 551 | | } |
| | | 552 | | |
| | | 553 | | /// <summary>У документа есть фактура в Armtek (getInvoice): виды из кода сайта, а без кода — по названию.</summary> |
| | | 554 | | internal static bool IsInvoiceDocument(string? docType, string? docTypeName) => |
| | | 555 | | docType != null ? InvoiceDocTypes.Contains(docType) : DocTypeByName(docTypeName) != null; |
| | | 556 | | |
| | | 557 | | private async Task<(List<ArmtekSettlement> Rows, int Created)> UpsertSettlementsAsync( |
| | | 558 | | IReadOnlyList<JsonElement> rows, CancellationToken ct) |
| | | 559 | | { |
| | | 560 | | var now = DateTime.UtcNow; |
| | | 561 | | var parsed = rows |
| | | 562 | | .Select(r => (Kunnr: ArmtekEtpParser.Str(r, "KUNNR"), Vbeln: ArmtekEtpParser.Str(r, "VBELN"), Row: r)) |
| | | 563 | | .Where(x => x.Kunnr != null && x.Vbeln != null) |
| | | 564 | | .GroupBy(x => (x.Kunnr!, x.Vbeln!)) |
| | | 565 | | .Select(g => g.Last()) |
| | | 566 | | .ToList(); |
| | | 567 | | |
| | | 568 | | var vbelns = parsed.Select(x => x.Vbeln!).Distinct().ToList(); |
| | | 569 | | var existing = await _context.ArmtekSettlements |
| | | 570 | | .Where(x => vbelns.Contains(x.Vbeln)) |
| | | 571 | | .ToListAsync(ct); |
| | | 572 | | var byKey = existing.ToDictionary(x => (x.Kunnr, x.Vbeln)); |
| | | 573 | | |
| | | 574 | | var result = new List<ArmtekSettlement>(parsed.Count); |
| | | 575 | | var created = 0; |
| | | 576 | | foreach (var (kunnr, vbeln, row) in parsed) |
| | | 577 | | { |
| | | 578 | | if (!byKey.TryGetValue((kunnr!, vbeln!), out var entity)) |
| | | 579 | | { |
| | | 580 | | entity = new ArmtekSettlement { Kunnr = kunnr!, Vbeln = vbeln!, FirstSeenAtUtc = now }; |
| | | 581 | | _context.ArmtekSettlements.Add(entity); |
| | | 582 | | created++; |
| | | 583 | | } |
| | | 584 | | MapSettlement(entity, row); |
| | | 585 | | entity.UpdatedAtUtc = now; |
| | | 586 | | result.Add(entity); |
| | | 587 | | } |
| | | 588 | | |
| | | 589 | | await _context.SaveChangesAsync(ct); |
| | | 590 | | return (result, created); |
| | | 591 | | } |
| | | 592 | | |
| | | 593 | | internal static void MapSettlement(ArmtekSettlement e, JsonElement r) |
| | | 594 | | { |
| | | 595 | | e.ClientName = Cut(ArmtekEtpParser.Str(r, "NAME"), 255); |
| | | 596 | | e.DocDate = ArmtekEtpParser.SapDate(r, "DOCDATE"); |
| | | 597 | | e.DocType = Cut(ArmtekEtpParser.Str(r, "FKART"), 10); |
| | | 598 | | e.DocTypeName = Cut(ArmtekEtpParser.Str(r, "FKART_NAME"), 255); |
| | | 599 | | e.DocNumber = Cut(ArmtekEtpParser.Str(r, "DOCNUM"), 50); |
| | | 600 | | e.PrintNumber = Cut(ArmtekEtpParser.Str(r, "ZZSF"), 50); |
| | | 601 | | e.PaymentMethod = Cut(ArmtekEtpParser.Str(r, "ZWELS"), 10); |
| | | 602 | | e.PaymentMethodName = Cut(ArmtekEtpParser.Str(r, "ZWELS_NAME"), 255); |
| | | 603 | | e.Amount = ArmtekEtpParser.Dec(r, "NETWR") ?? 0; |
| | | 604 | | e.ReturnAmount = ArmtekEtpParser.Dec(r, "NETWR_RE") ?? 0; |
| | | 605 | | e.PaymentAmount = ArmtekEtpParser.Dec(r, "NETWR_PAY") ?? 0; |
| | | 606 | | e.RemainderAmount = ArmtekEtpParser.Dec(r, "NETWR_REM") ?? 0; |
| | | 607 | | e.RemainderDate = ArmtekEtpParser.SapDate(r, "REMDATE"); |
| | | 608 | | e.PayUntil = ArmtekEtpParser.SapDate(r, "PAYDATE"); |
| | | 609 | | e.Balance = ArmtekEtpParser.Dec(r, "SKFOR"); |
| | | 610 | | e.Currency = Cut(ArmtekEtpParser.Str(r, "WAERK"), 5); |
| | | 611 | | e.Contract = Cut(ArmtekEtpParser.Str(r, "BSTKD"), 100); |
| | | 612 | | e.Division = Cut(ArmtekEtpParser.Str(r, "SPART"), 10); |
| | | 613 | | e.DivisionName = Cut(ArmtekEtpParser.Str(r, "SPART_NAME"), 255); |
| | | 614 | | e.CompanyCode = Cut(ArmtekEtpParser.Str(r, "BUKRS"), 10); |
| | | 615 | | e.CompanyName = Cut(ArmtekEtpParser.Str(r, "BUKRSN"), 255); |
| | | 616 | | e.RawJson = r.GetRawText(); |
| | | 617 | | } |
| | | 618 | | |
| | | 619 | | private static DateTime? ParseDate(string? s, string format) => |
| | | 620 | | DateTime.TryParseExact(s?.Trim(), format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d : nu |
| | | 621 | | |
| | | 622 | | private static string? Cut(string? s, int max) => s == null ? null : s.Length <= max ? s : s[..max]; |
| | | 623 | | } |
| | | 624 | | |
| | | 625 | | /// <summary>Фактуры по API не загрузились; результат — то, что успело загрузиться (взаиморасчёты из кабинета).</summary |
| | | 626 | | public sealed class ArmtekSyncPartialException(string message, ArmtekEtpSyncResult result, Exception inner) : Exception( |
| | | 627 | | { |
| | | 628 | | public ArmtekEtpSyncResult Result { get; } = result; |
| | | 629 | | } |