| | | 1 | | using System.Linq.Expressions; |
| | | 2 | | using Microsoft.EntityFrameworkCore; |
| | | 3 | | using OzonDomains.Models.ArmtekEtp; |
| | | 4 | | using OzonOrdersWeb.WebServices.Grid; |
| | | 5 | | using OzonRepositories.Context; |
| | | 6 | | |
| | | 7 | | namespace OzonOrdersWeb.WebServices.Transactions; |
| | | 8 | | |
| | | 9 | | /// <param name="Number">Значение для поля «Номер вх. документа».</param> |
| | | 10 | | /// <param name="Kind">Что это за номер: фактура, УПД или ТТН.</param> |
| | | 11 | | /// <param name="Matched">Сколько артикулов заказов найдено в этой фактуре.</param> |
| | | 12 | | /// <param name="Total">Сколько разных артикулов в выбранных заказах.</param> |
| | 17 | 13 | | public sealed record InvoiceNumberSuggestion(string Number, string Kind, DateTime? InvoiceDate, int Matched, int Total, |
| | | 14 | | |
| | | 15 | | /// <param name="Country">Страна в написании справочника 1С; null — в 1С такой страны нет (причина в <paramref name="Err |
| | | 16 | | public sealed record OrderCountrySuggestion(int OrderId, string Article, string? SourceCountry, string? Country, string? |
| | | 17 | | |
| | | 18 | | /// <summary> |
| | | 19 | | /// Подсказки для поля «Номер вх. документа» формы прихода: по набору артикулов выбранных заказов ищет позиции фактур |
| | | 20 | | /// Армтека (артикул и код Армтек сравниваются без разделителей: 6025-2309-SX = 60252309_SX = 60252309SX) и предлагает |
| | | 21 | | /// номера найденных фактур, начиная с тех, что покрывают больше артикулов. |
| | | 22 | | /// </summary> |
| | | 23 | | public sealed class ArmtekInvoiceNumberSuggestService(ApplicationDbContext db) |
| | | 24 | | { |
| | | 25 | | private const int MaxInvoices = 8; |
| | | 26 | | private const int MaxItems = 3000; |
| | | 27 | | |
| | | 28 | | public sealed record FoundItem(long InvoiceId, string? Article, string? ArmtekCode, string Vbeln, string? UpdNumber, |
| | | 29 | | string? TtnNumber, DateTime? InvoiceDate, string? Brand = null, string? Country = null); |
| | | 30 | | |
| | | 31 | | public sealed record OrderRef(int Id, string? Article, string? ProductKey, string? Brand); |
| | | 32 | | |
| | | 33 | | public async Task<IReadOnlyList<InvoiceNumberSuggestion>> SuggestAsync(int[] orderIds, CancellationToken ct) |
| | | 34 | | { |
| | | 35 | | var orders = await LoadOrdersAsync(orderIds, ct); |
| | | 36 | | var keys = OrderArticleKeys(orders.Select(o => (o.Article, o.ProductKey))); |
| | | 37 | | if (keys.Count == 0) return []; |
| | | 38 | | return Build(await FindItemsAsync(keys, ct), keys); |
| | | 39 | | } |
| | | 40 | | |
| | | 41 | | /// <summary> |
| | | 42 | | /// Страна происхождения по артикулу заказа из позиций фактур Армтека, сразу в написании справочника 1С: |
| | | 43 | | /// «(CN, 156) Китай» → «КИТАЙ». Заказы, по которым в фактурах ничего нет, в ответ не попадают. |
| | | 44 | | /// </summary> |
| | | 45 | | public async Task<IReadOnlyList<OrderCountrySuggestion>> SuggestCountriesAsync( |
| | | 46 | | int[] orderIds, IReadOnlyCollection<string> oneCCountries, CancellationToken ct) |
| | | 47 | | { |
| | | 48 | | var orders = await LoadOrdersAsync(orderIds, ct); |
| | | 49 | | var keys = OrderArticleKeys(orders.Select(o => (o.Article, o.ProductKey))); |
| | | 50 | | if (keys.Count == 0) return []; |
| | | 51 | | return PickCountries(orders, await FindItemsAsync(keys, ct), oneCCountries); |
| | | 52 | | } |
| | | 53 | | |
| | | 54 | | private async Task<List<OrderRef>> LoadOrdersAsync(int[] orderIds, CancellationToken ct) |
| | | 55 | | => await db.Orders.AsNoTracking().Where(o => orderIds.Contains(o.Id)) |
| | | 56 | | .Select(o => new OrderRef(o.Id, o.Article, o.ProductKey, o.EtProducer != null ? o.EtProducer.Name : null)) |
| | | 57 | | .ToListAsync(ct); |
| | | 58 | | |
| | | 59 | | private async Task<List<FoundItem>> FindItemsAsync(List<string> keys, CancellationToken ct) |
| | | 60 | | { |
| | | 61 | | Expression<Func<ArmtekInvoiceItem, string?>> article = x => x.Article; |
| | | 62 | | Expression<Func<ArmtekInvoiceItem, string?>> code = x => x.ArmtekCode; |
| | | 63 | | var parameter = Expression.Parameter(typeof(ArmtekInvoiceItem), "x"); |
| | | 64 | | var predicate = Expression.Lambda<Func<ArmtekInvoiceItem, bool>>( |
| | | 65 | | Expression.OrElse(KeyIn(keys, article, parameter), KeyIn(keys, code, parameter)), parameter); |
| | | 66 | | |
| | | 67 | | return await db.ArmtekInvoiceItems.AsNoTracking().Where(predicate) |
| | | 68 | | .OrderByDescending(x => x.Invoice!.InvoiceDate).Take(MaxItems) |
| | | 69 | | .Select(x => new FoundItem(x.InvoiceId, x.Article, x.ArmtekCode, x.Invoice!.Vbeln, x.Invoice.UpdNumber, |
| | | 70 | | x.Invoice.TtnNumber, x.Invoice.InvoiceDate, x.Brand, x.Country)) |
| | | 71 | | .ToListAsync(ct); |
| | | 72 | | } |
| | | 73 | | |
| | | 74 | | /// <summary> |
| | | 75 | | /// Для каждого заказа берётся самая свежая позиция фактуры с тем же артикулом (сначала с тем же брендом) и страной, |
| | | 76 | | /// название страны сверяется со справочником 1С без учёта регистра и «ё». |
| | | 77 | | /// </summary> |
| | | 78 | | public static IReadOnlyList<OrderCountrySuggestion> PickCountries( |
| | | 79 | | IEnumerable<OrderRef> orders, IReadOnlyCollection<FoundItem> items, IReadOnlyCollection<string> oneCCountries) |
| | | 80 | | { |
| | | 81 | | var result = new List<OrderCountrySuggestion>(); |
| | | 82 | | foreach (var order in orders) |
| | | 83 | | { |
| | | 84 | | var key = OrderArticleKeys([(order.Article, order.ProductKey)]).FirstOrDefault(); |
| | | 85 | | if (key == null) continue; |
| | | 86 | | var brand = ServerGridQuery.ArticleKey(order.Brand); |
| | | 87 | | var pick = items |
| | | 88 | | .Where(i => (ServerGridQuery.ArticleKey(i.Article) == key || ServerGridQuery.ArticleKey(i.ArmtekCode) == |
| | | 89 | | && HasCountry(i.Country)) |
| | | 90 | | .OrderByDescending(i => brand.Length > 0 && ServerGridQuery.ArticleKey(i.Brand) == brand) |
| | | 91 | | .ThenByDescending(i => i.InvoiceDate) |
| | | 92 | | .FirstOrDefault(); |
| | | 93 | | if (pick == null) continue; |
| | | 94 | | |
| | | 95 | | var name = ArmtekReceiptOriginService.CountryName(pick.Country); |
| | | 96 | | var canonical = oneCCountries.FirstOrDefault(c => CountryKey(c) == CountryKey(name)); |
| | | 97 | | result.Add(new OrderCountrySuggestion(order.Id, order.Article ?? string.Empty, pick.Country, canonical, |
| | | 98 | | canonical == null ? $"Страна «{name}» не найдена в справочнике 1С" : null)); |
| | | 99 | | } |
| | | 100 | | |
| | | 101 | | return result; |
| | | 102 | | } |
| | | 103 | | |
| | | 104 | | /// <summary>Страна в фактуре указана: «(CN, 156) Китай» — да, «(JP, 392)» без названия и «- -» — нет.</summary> |
| | | 105 | | private static bool HasCountry(string? value) => ArmtekReceiptOriginService.CountryName(value).Any(char.IsLetter); |
| | | 106 | | |
| | | 107 | | private static string CountryKey(string? value) |
| | | 108 | | => ServerGridQuery.ArticleKey(value?.Replace('ё', 'е').Replace('Ё', 'Е')); |
| | | 109 | | |
| | | 110 | | /// <summary>Уникальные ключи артикулов заказов; без артикула берётся часть ключа товара до «=».</summary> |
| | | 111 | | public static List<string> OrderArticleKeys(IEnumerable<(string? Article, string? ProductKey)> orders) |
| | | 112 | | => orders.Select(o => ServerGridQuery.ArticleKey(!string.IsNullOrWhiteSpace(o.Article) ? o.Article : ProductKeyA |
| | | 113 | | .Where(k => k.Length > 0).Distinct().ToList(); |
| | | 114 | | |
| | | 115 | | private static string? ProductKeyArticle(string? productKey) |
| | | 116 | | => productKey?.Split('=')[0]; |
| | | 117 | | |
| | | 118 | | /// <summary>Фактуры по числу покрытых артикулов (по убыванию), затем по дате; номера: фактура, УПД, ТТН.</summary> |
| | | 119 | | public static IReadOnlyList<InvoiceNumberSuggestion> Build(IEnumerable<FoundItem> items, IReadOnlyCollection<string> |
| | | 120 | | { |
| | | 121 | | var result = new List<InvoiceNumberSuggestion>(); |
| | | 122 | | var invoices = items.GroupBy(i => i.InvoiceId) |
| | | 123 | | .Select(g => new |
| | | 124 | | { |
| | | 125 | | First = g.First(), |
| | | 126 | | Matched = keys.Count(k => g.Any(i => ServerGridQuery.ArticleKey(i.Article) == k || ServerGridQuery.Artic |
| | | 127 | | }) |
| | | 128 | | .Where(x => x.Matched > 0) |
| | | 129 | | .OrderByDescending(x => x.Matched).ThenByDescending(x => x.First.InvoiceDate) |
| | | 130 | | .Take(MaxInvoices); |
| | | 131 | | |
| | | 132 | | var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | | 133 | | foreach (var invoice in invoices) |
| | | 134 | | { |
| | | 135 | | var first = invoice.First; |
| | | 136 | | var coverage = $"артикулов {invoice.Matched} из {keys.Count}"; |
| | | 137 | | var date = first.InvoiceDate?.ToString("dd.MM.yyyy"); |
| | | 138 | | foreach (var (number, kind) in new[] { (first.Vbeln, "фактура"), (first.UpdNumber, "УПД"), (first.TtnNumber, |
| | | 139 | | { |
| | | 140 | | if (string.IsNullOrWhiteSpace(number) || !seen.Add(number.Trim())) continue; |
| | | 141 | | var label = $"{kind}{(date != null ? $" от {date}" : string.Empty)} · {coverage}"; |
| | | 142 | | result.Add(new InvoiceNumberSuggestion(number.Trim(), kind, first.InvoiceDate, invoice.Matched, keys.Cou |
| | | 143 | | } |
| | | 144 | | } |
| | | 145 | | |
| | | 146 | | return result; |
| | | 147 | | } |
| | | 148 | | |
| | | 149 | | /// <summary><c>keys.Contains(REPLACE(…поле…))</c> — поле сравнивается без разделителей на стороне БД.</summary> |
| | | 150 | | private static Expression KeyIn(List<string> keys, Expression<Func<ArmtekInvoiceItem, string?>> field, ParameterExpr |
| | | 151 | | { |
| | | 152 | | var body = new ParameterReplacer(field.Parameters[0], parameter).Visit(field.Body)!; |
| | | 153 | | return Expression.Call(typeof(Enumerable), nameof(Enumerable.Contains), new[] { typeof(string) }, |
| | | 154 | | Expression.Constant(keys), ServerGridQuery.StripSeparatorsExpression(body)); |
| | | 155 | | } |
| | | 156 | | |
| | | 157 | | private sealed class ParameterReplacer(ParameterExpression from, ParameterExpression to) : ExpressionVisitor |
| | | 158 | | { |
| | | 159 | | protected override Expression VisitParameter(ParameterExpression node) => node == from ? to : base.VisitParamete |
| | | 160 | | } |
| | | 161 | | } |