diff --git a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs new file mode 100644 index 0000000..5380ba4 --- /dev/null +++ b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs @@ -0,0 +1,40 @@ +using foodmarket.Infrastructure.Integrations.MoySklad; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace foodmarket.Api.Controllers.Admin; + +[ApiController] +[Authorize(Roles = "Admin,SuperAdmin")] +[Route("api/admin/moysklad")] +public class MoySkladImportController : ControllerBase +{ + private readonly MoySkladImportService _svc; + + public MoySkladImportController(MoySkladImportService svc) => _svc = svc; + + public record TestRequest(string Token); + public record ImportRequest(string Token, bool OverwriteExisting = false); + + [HttpPost("test")] + public async Task TestConnection([FromBody] TestRequest req, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(req.Token)) + return BadRequest(new { error = "Token is required." }); + + var org = await _svc.TestConnectionAsync(req.Token, ct); + return org is null + ? Unauthorized(new { error = "Токен недействителен или нет доступа к API." }) + : Ok(new { organization = org.Name, inn = org.Inn }); + } + + [HttpPost("import-products")] + public async Task> ImportProducts([FromBody] ImportRequest req, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(req.Token)) + return BadRequest(new { error = "Token is required." }); + + var result = await _svc.ImportProductsAsync(req.Token, req.OverwriteExisting, ct); + return result; + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index fdfac4a..57058ca 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -94,6 +94,10 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); + // MoySklad import integration + builder.Services.AddHttpClient(); + builder.Services.AddScoped(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs new file mode 100644 index 0000000..35d6207 --- /dev/null +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs @@ -0,0 +1,78 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +namespace foodmarket.Infrastructure.Integrations.MoySklad; + +// Thin HTTP wrapper over MoySklad JSON-API 1.2. Caller supplies the personal token per request +// — we never persist it. +public class MoySkladClient +{ + private const string BaseUrl = "https://api.moysklad.ru/api/remap/1.2"; + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + private readonly HttpClient _http; + + public MoySkladClient(HttpClient http) + { + _http = http; + _http.BaseAddress ??= new Uri(BaseUrl); + _http.Timeout = TimeSpan.FromSeconds(90); + } + + private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string token) + { + var req = new HttpRequestMessage(method, pathAndQuery); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + req.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip")); + return req; + } + + public async Task WhoAmIAsync(string token, CancellationToken ct) + { + using var req = Build(HttpMethod.Get, "entity/organization?limit=1", token); + using var res = await _http.SendAsync(req, ct); + if (!res.IsSuccessStatusCode) return null; + var list = await res.Content.ReadFromJsonAsync>(Json, ct); + return list?.Rows.FirstOrDefault(); + } + + public async IAsyncEnumerable StreamProductsAsync( + string token, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + const int pageSize = 1000; + var offset = 0; + while (true) + { + using var req = Build(HttpMethod.Get, $"entity/product?limit={pageSize}&offset={offset}", token); + using var res = await _http.SendAsync(req, ct); + res.EnsureSuccessStatusCode(); + var page = await res.Content.ReadFromJsonAsync>(Json, ct); + if (page is null || page.Rows.Count == 0) yield break; + foreach (var p in page.Rows) yield return p; + if (page.Rows.Count < pageSize) yield break; + offset += pageSize; + } + } + + public async Task> GetAllFoldersAsync(string token, CancellationToken ct) + { + var all = new List(); + var offset = 0; + const int pageSize = 1000; + while (true) + { + using var req = Build(HttpMethod.Get, $"entity/productfolder?limit={pageSize}&offset={offset}", token); + using var res = await _http.SendAsync(req, ct); + res.EnsureSuccessStatusCode(); + var page = await res.Content.ReadFromJsonAsync>(Json, ct); + if (page is null || page.Rows.Count == 0) break; + all.AddRange(page.Rows); + if (page.Rows.Count < pageSize) break; + offset += pageSize; + } + return all; + } +} diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs new file mode 100644 index 0000000..a656300 --- /dev/null +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs @@ -0,0 +1,127 @@ +using System.Text.Json.Serialization; + +namespace foodmarket.Infrastructure.Integrations.MoySklad; + +// Minimal MoySklad JSON-API response shapes we need for read-only product import. +// See https://dev.moysklad.ru/doc/api/remap/1.2/ — we intentionally ignore fields we don't consume. + +public class MsListResponse +{ + [JsonPropertyName("meta")] public MsListMeta? Meta { get; set; } + [JsonPropertyName("rows")] public List Rows { get; set; } = []; +} + +public class MsListMeta +{ + [JsonPropertyName("size")] public int Size { get; set; } + [JsonPropertyName("limit")] public int Limit { get; set; } + [JsonPropertyName("offset")] public int Offset { get; set; } + [JsonPropertyName("href")] public string? Href { get; set; } +} + +public class MsMeta +{ + [JsonPropertyName("href")] public string? Href { get; set; } + [JsonPropertyName("type")] public string? Type { get; set; } + [JsonPropertyName("uuidHref")] public string? UuidHref { get; set; } +} + +public class MsMetaWrapper +{ + [JsonPropertyName("meta")] public MsMeta? Meta { get; set; } + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("code")] public string? Code { get; set; } +} + +public class MsOrganization +{ + [JsonPropertyName("id")] public string? Id { get; set; } + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("inn")] public string? Inn { get; set; } + [JsonPropertyName("kpp")] public string? Kpp { get; set; } +} + +public class MsProduct +{ + [JsonPropertyName("id")] public string? Id { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } = ""; + [JsonPropertyName("article")] public string? Article { get; set; } + [JsonPropertyName("code")] public string? Code { get; set; } + [JsonPropertyName("description")] public string? Description { get; set; } + [JsonPropertyName("weighed")] public bool Weighed { get; set; } + [JsonPropertyName("vat")] public int? Vat { get; set; } + [JsonPropertyName("vatEnabled")] public bool? VatEnabled { get; set; } + [JsonPropertyName("archived")] public bool Archived { get; set; } + + [JsonPropertyName("barcodes")] public List>? Barcodes { get; set; } + [JsonPropertyName("salePrices")] public List? SalePrices { get; set; } + [JsonPropertyName("buyPrice")] public MsMoney? BuyPrice { get; set; } + [JsonPropertyName("minPrice")] public MsMoney? MinPrice { get; set; } + + [JsonPropertyName("uom")] public MsMetaWrapper? Uom { get; set; } + [JsonPropertyName("productFolder")] public MsMetaWrapper? ProductFolder { get; set; } + [JsonPropertyName("country")] public MsMetaWrapper? Country { get; set; } + + [JsonPropertyName("alcoholic")] public MsAlcoholic? Alcoholic { get; set; } + [JsonPropertyName("tnved")] public string? Tnved { get; set; } + [JsonPropertyName("trackingType")] public string? TrackingType { get; set; } +} + +public class MsSalePrice +{ + [JsonPropertyName("value")] public long Value { get; set; } // minor units (копейки/тиын) + [JsonPropertyName("currency")] public MsMetaWrapper? Currency { get; set; } + [JsonPropertyName("priceType")] public MsPriceType? PriceType { get; set; } +} + +public class MsPriceType +{ + [JsonPropertyName("id")] public string? Id { get; set; } + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("externalCode")] public string? ExternalCode { get; set; } +} + +public class MsMoney +{ + [JsonPropertyName("value")] public long Value { get; set; } + [JsonPropertyName("currency")] public MsMetaWrapper? Currency { get; set; } +} + +public class MsAlcoholic +{ + [JsonPropertyName("type")] public string? Type { get; set; } + [JsonPropertyName("strength")] public double? Strength { get; set; } +} + +public class MsCurrency +{ + [JsonPropertyName("id")] public string? Id { get; set; } + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("isoCode")] public string? IsoCode { get; set; } + [JsonPropertyName("rate")] public double? Rate { get; set; } +} + +public class MsUom +{ + [JsonPropertyName("id")] public string? Id { get; set; } + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("code")] public string? Code { get; set; } + [JsonPropertyName("description")] public string? Description { get; set; } +} + +public class MsProductFolder +{ + [JsonPropertyName("id")] public string? Id { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } = ""; + [JsonPropertyName("pathName")] public string? PathName { get; set; } + [JsonPropertyName("productFolder")] public MsMetaWrapper? ParentFolder { get; set; } + [JsonPropertyName("archived")] public bool Archived { get; set; } +} + +public class MsCountry +{ + [JsonPropertyName("id")] public string? Id { get; set; } + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("code")] public string? Code { get; set; } + [JsonPropertyName("externalCode")] public string? ExternalCode { get; set; } +} diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs new file mode 100644 index 0000000..c1176bf --- /dev/null +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs @@ -0,0 +1,215 @@ +using foodmarket.Application.Common.Tenancy; +using foodmarket.Domain.Catalog; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace foodmarket.Infrastructure.Integrations.MoySklad; + +public record MoySkladImportResult( + int Total, + int Created, + int Skipped, + int GroupsCreated, + IReadOnlyList Errors); + +public class MoySkladImportService +{ + private readonly MoySkladClient _client; + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + private readonly ILogger _log; + + public MoySkladImportService( + MoySkladClient client, + AppDbContext db, + ITenantContext tenant, + ILogger log) + { + _client = client; + _db = db; + _tenant = tenant; + _log = log; + } + + public async Task TestConnectionAsync(string token, CancellationToken ct) + { + return await _client.WhoAmIAsync(token, ct); + } + + public async Task ImportProductsAsync( + string token, + bool overwriteExisting, + CancellationToken ct) + { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); + + // Pre-load tenant defaults. + var defaultVat = await _db.VatRates.FirstOrDefaultAsync(v => v.IsDefault, ct) + ?? await _db.VatRates.FirstAsync(ct); + var vatByPercent = await _db.VatRates.ToDictionaryAsync(v => (int)v.Percent, v => v.Id, ct); + var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.IsBase, ct) + ?? await _db.UnitsOfMeasure.FirstAsync(ct); + var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct) + ?? await _db.PriceTypes.FirstAsync(ct); + var kzt = await _db.Currencies.FirstAsync(c => c.Code == "KZT", ct); + + var countriesByName = await _db.Countries + .IgnoreQueryFilters() + .ToDictionaryAsync(c => c.Name, c => c.Id, ct); + + // Import folders first — build flat then link parents. + var folders = await _client.GetAllFoldersAsync(token, ct); + var localGroupByMsId = new Dictionary(); + var groupsCreated = 0; + foreach (var f in folders.Where(f => !f.Archived).OrderBy(f => f.PathName?.Length ?? 0)) + { + if (f.Id is null) continue; + var existing = await _db.ProductGroups.FirstOrDefaultAsync( + g => g.Name == f.Name && g.Path == (f.PathName ?? f.Name), ct); + if (existing is not null) + { + localGroupByMsId[f.Id] = existing.Id; + continue; + } + var g = new ProductGroup + { + OrganizationId = orgId, + Name = f.Name, + Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}", + IsActive = true, + }; + _db.ProductGroups.Add(g); + localGroupByMsId[f.Id] = g.Id; + groupsCreated++; + } + if (groupsCreated > 0) await _db.SaveChangesAsync(ct); + + // Import products + var errors = new List(); + var created = 0; + var skipped = 0; + var total = 0; + var existingArticles = await _db.Products + .Where(p => p.Article != null) + .Select(p => p.Article!) + .ToListAsync(ct); + var existingArticleSet = new HashSet(existingArticles, StringComparer.OrdinalIgnoreCase); + var existingBarcodeSet = new HashSet( + await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct)); + + await foreach (var p in _client.StreamProductsAsync(token, ct)) + { + total++; + if (p.Archived) { skipped++; continue; } + + var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article; + var primaryBarcode = ExtractBarcodes(p).FirstOrDefault(); + + var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingArticleSet.Contains(article); + var alreadyByBarcode = primaryBarcode is not null && existingBarcodeSet.Contains(primaryBarcode.Code); + + if ((alreadyByArticle || alreadyByBarcode) && !overwriteExisting) + { + skipped++; + continue; + } + + try + { + var vatId = p.Vat is int vp && vatByPercent.TryGetValue(vp, out var id) ? id : defaultVat.Id; + Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId + && localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null; + Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null; + + var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true) + ?? p.SalePrices?.FirstOrDefault(); + + var product = new Product + { + OrganizationId = orgId, + Name = p.Name, + Article = article, + Description = p.Description, + UnitOfMeasureId = baseUnit.Id, + VatRateId = vatId, + ProductGroupId = groupId, + CountryOfOriginId = countryId, + IsWeighed = p.Weighed, + IsAlcohol = p.Alcoholic is not null, + IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED", + IsActive = !p.Archived, + PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m, + PurchaseCurrencyId = kzt.Id, + }; + + if (retailPrice is not null) + { + product.Prices.Add(new ProductPrice + { + OrganizationId = orgId, + PriceTypeId = retailType.Id, + Amount = retailPrice.Value / 100m, + CurrencyId = kzt.Id, + }); + } + + foreach (var b in ExtractBarcodes(p)) + { + if (existingBarcodeSet.Contains(b.Code)) continue; + product.Barcodes.Add(b); + existingBarcodeSet.Add(b.Code); + } + + _db.Products.Add(product); + if (!string.IsNullOrWhiteSpace(article)) existingArticleSet.Add(article); + created++; + + // Flush every 500 products to keep change tracker light. + if (created % 500 == 0) await _db.SaveChangesAsync(ct); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Failed to import MoySklad product {Name}", p.Name); + errors.Add($"{p.Name}: {ex.Message}"); + } + } + + await _db.SaveChangesAsync(ct); + return new MoySkladImportResult(total, created, skipped, groupsCreated, errors); + } + + private static List ExtractBarcodes(MsProduct p) + { + if (p.Barcodes is null) return []; + var list = new List(); + var primarySet = false; + foreach (var entry in p.Barcodes) + { + foreach (var (kind, code) in entry) + { + if (string.IsNullOrWhiteSpace(code)) continue; + var type = kind switch + { + "ean13" => BarcodeType.Ean13, + "ean8" => BarcodeType.Ean8, + "code128" => BarcodeType.Code128, + "gtin" => BarcodeType.Ean13, + "upca" => BarcodeType.Upca, + "upce" => BarcodeType.Upce, + _ => BarcodeType.Other, + }; + list.Add(new ProductBarcode { Code = code, Type = type, IsPrimary = !primarySet }); + primarySet = true; + } + } + return list; + } + + private static string? TryExtractId(string href) + { + // href like "https://api.moysklad.ru/api/remap/1.2/entity/productfolder/" + var lastSlash = href.LastIndexOf('/'); + return lastSlash >= 0 ? href[(lastSlash + 1)..] : null; + } +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index a4c2521..71dea16 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -13,6 +13,7 @@ import { ProductGroupsPage } from '@/pages/ProductGroupsPage' import { CounterpartiesPage } from '@/pages/CounterpartiesPage' import { ProductsPage } from '@/pages/ProductsPage' import { ProductEditPage } from '@/pages/ProductEditPage' +import { MoySkladImportPage } from '@/pages/MoySkladImportPage' import { AppLayout } from '@/components/AppLayout' import { ProtectedRoute } from '@/components/ProtectedRoute' @@ -46,6 +47,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index 165990c..ae738ab 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -5,7 +5,7 @@ import { logout } from '@/lib/auth' import { cn } from '@/lib/utils' import { LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag, - Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, + Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download, } from 'lucide-react' import { Logo } from './Logo' @@ -39,6 +39,9 @@ const nav = [ { to: '/catalog/countries', icon: Globe, label: 'Страны' }, { to: '/catalog/currencies', icon: Coins, label: 'Валюты' }, ]}, + { group: 'Импорт', items: [ + { to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' }, + ]}, ] as const export function AppLayout() { diff --git a/src/food-market.web/src/pages/MoySkladImportPage.tsx b/src/food-market.web/src/pages/MoySkladImportPage.tsx new file mode 100644 index 0000000..70e7854 --- /dev/null +++ b/src/food-market.web/src/pages/MoySkladImportPage.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { AlertCircle, CheckCircle, Download, KeyRound } from 'lucide-react' +import { api } from '@/lib/api' +import { PageHeader } from '@/components/PageHeader' +import { Button } from '@/components/Button' +import { Field, TextInput, Checkbox } from '@/components/Field' + +interface TestResponse { organization: string; inn?: string | null } +interface ImportResponse { + total: number + created: number + skipped: number + groupsCreated: number + errors: string[] +} + +export function MoySkladImportPage() { + const qc = useQueryClient() + const [token, setToken] = useState('') + const [overwrite, setOverwrite] = useState(false) + + const test = useMutation({ + mutationFn: async () => (await api.post('/api/admin/moysklad/test', { token })).data, + }) + + const run = useMutation({ + mutationFn: async () => (await api.post('/api/admin/moysklad/import-products', { token, overwriteExisting: overwrite })).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }), + }) + + return ( +
+ + +
+
+ +
+

Токен не сохраняется — передаётся только в текущий запрос и не пишется ни в БД, ни в логи.

+

Получить токен: online.moysklad.ru/app → Настройки аккаунта → Сервисный аккаунт → создать токен (read-only прав достаточно).

+

Рекомендуется отдельный сервисный аккаунт с правом только «Просмотр: Товары».

+
+
+
+ +
+ + setToken(e.target.value)} + placeholder="персональный токен или токен сервисного аккаунта" + autoComplete="off" + spellCheck={false} + /> + + +
+ + + {test.data && ( +
+ + Подключено: {test.data.organization} + {test.data.inn && (ИНН {test.data.inn})} +
+ )} + {test.error && ( +
+ {(test.error as Error).message} +
+ )} +
+ +
+ + +
+
+ + {run.data && ( +
+

+ Импорт завершён +

+
+
+
Всего получено
+
{run.data.total}
+
+
+
Создано
+
{run.data.created}
+
+
+
Пропущено
+
{run.data.skipped}
+
+
+
Групп создано
+
{run.data.groupsCreated}
+
+
+ + {run.data.errors.length > 0 && ( +
+ + Ошибок: {run.data.errors.length} (развернуть) + +
    + {run.data.errors.map((e, i) =>
  • {e}
  • )} +
+
+ )} +
+ )} + + {run.error && ( +
+ Ошибка: {(run.error as Error).message} +
+ )} +
+ ) +}