phase1e: MoySklad import integration (admin-only, per-request token, no persistence)
Infrastructure (foodmarket.Infrastructure.Integrations.MoySklad):
- MoySkladDtos: minimal shapes of products, folders, uom, prices, barcodes from JSON-API 1.2
- MoySkladClient: HttpClient wrapper with Bearer auth per call
- WhoAmIAsync (GET entity/organization) for connection test
- StreamProductsAsync (paginated 1000/page, IAsyncEnumerable)
- GetAllFoldersAsync (all product folders in one go)
- MoySkladImportService: orchestrates the full import
- Creates missing product folders with Path preserved
- Maps MoySklad VAT percent → local VatRate (fallback to default)
- Maps barcodes: ean13/ean8/code128/gtin/upca/upce → our BarcodeType enum
- Extracts retail price from salePrices (prefers "Розничная"), divides kopeck→major
- Extracts buyPrice → PurchasePrice
- Skips existing products by article OR primary barcode (unless overwrite flag set)
- Batch SaveChanges every 500 items to keep EF tracker light
- Returns counts + per-item error list
API: POST /api/admin/moysklad/test — returns org name if token valid
API: POST /api/admin/moysklad/import-products { token, overwriteExisting }
— Authorize(Roles = "Admin,SuperAdmin")
Web: /admin/import/moysklad page
- Amber notice: token is not persisted (request-scope only), how to create
a service token in moysklad.ru with read-only rights
- Test connection button + result banner
- Import button with "overwrite existing" checkbox
- Result panel with 4 counters + collapsible error list
Sidebar adds "Импорт" section with MoySklad link.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5af8f74b5e
commit
25f25f9171
|
|
@ -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<IActionResult> 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<ActionResult<MoySkladImportResult>> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -94,6 +94,10 @@
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
// MoySklad import integration
|
||||||
|
builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>();
|
||||||
|
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
||||||
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
||||||
builder.Services.AddHostedService<DevDataSeeder>();
|
builder.Services.AddHostedService<DevDataSeeder>();
|
||||||
|
|
|
||||||
|
|
@ -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<MsOrganization?> 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<MsListResponse<MsOrganization>>(Json, ct);
|
||||||
|
return list?.Rows.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<MsProduct> 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<MsListResponse<MsProduct>>(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<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var all = new List<MsProductFolder>();
|
||||||
|
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<MsListResponse<MsProductFolder>>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<T>
|
||||||
|
{
|
||||||
|
[JsonPropertyName("meta")] public MsListMeta? Meta { get; set; }
|
||||||
|
[JsonPropertyName("rows")] public List<T> 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<Dictionary<string, string>>? Barcodes { get; set; }
|
||||||
|
[JsonPropertyName("salePrices")] public List<MsSalePrice>? 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; }
|
||||||
|
}
|
||||||
|
|
@ -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<string> Errors);
|
||||||
|
|
||||||
|
public class MoySkladImportService
|
||||||
|
{
|
||||||
|
private readonly MoySkladClient _client;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
private readonly ILogger<MoySkladImportService> _log;
|
||||||
|
|
||||||
|
public MoySkladImportService(
|
||||||
|
MoySkladClient client,
|
||||||
|
AppDbContext db,
|
||||||
|
ITenantContext tenant,
|
||||||
|
ILogger<MoySkladImportService> log)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_db = db;
|
||||||
|
_tenant = tenant;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MsOrganization?> TestConnectionAsync(string token, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await _client.WhoAmIAsync(token, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MoySkladImportResult> 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<string, Guid>();
|
||||||
|
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<string>();
|
||||||
|
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<string>(existingArticles, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var existingBarcodeSet = new HashSet<string>(
|
||||||
|
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<ProductBarcode> ExtractBarcodes(MsProduct p)
|
||||||
|
{
|
||||||
|
if (p.Barcodes is null) return [];
|
||||||
|
var list = new List<ProductBarcode>();
|
||||||
|
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/<guid>"
|
||||||
|
var lastSlash = href.LastIndexOf('/');
|
||||||
|
return lastSlash >= 0 ? href[(lastSlash + 1)..] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import { ProductGroupsPage } from '@/pages/ProductGroupsPage'
|
||||||
import { CounterpartiesPage } from '@/pages/CounterpartiesPage'
|
import { CounterpartiesPage } from '@/pages/CounterpartiesPage'
|
||||||
import { ProductsPage } from '@/pages/ProductsPage'
|
import { ProductsPage } from '@/pages/ProductsPage'
|
||||||
import { ProductEditPage } from '@/pages/ProductEditPage'
|
import { ProductEditPage } from '@/pages/ProductEditPage'
|
||||||
|
import { MoySkladImportPage } from '@/pages/MoySkladImportPage'
|
||||||
import { AppLayout } from '@/components/AppLayout'
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||||
|
|
||||||
|
|
@ -46,6 +47,7 @@ export default function App() {
|
||||||
<Route path="/catalog/retail-points" element={<RetailPointsPage />} />
|
<Route path="/catalog/retail-points" element={<RetailPointsPage />} />
|
||||||
<Route path="/catalog/countries" element={<CountriesPage />} />
|
<Route path="/catalog/countries" element={<CountriesPage />} />
|
||||||
<Route path="/catalog/currencies" element={<CurrenciesPage />} />
|
<Route path="/catalog/currencies" element={<CurrenciesPage />} />
|
||||||
|
<Route path="/admin/import/moysklad" element={<MoySkladImportPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { logout } from '@/lib/auth'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag,
|
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'
|
} from 'lucide-react'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
|
|
||||||
|
|
@ -39,6 +39,9 @@ const nav = [
|
||||||
{ to: '/catalog/countries', icon: Globe, label: 'Страны' },
|
{ to: '/catalog/countries', icon: Globe, label: 'Страны' },
|
||||||
{ to: '/catalog/currencies', icon: Coins, label: 'Валюты' },
|
{ to: '/catalog/currencies', icon: Coins, label: 'Валюты' },
|
||||||
]},
|
]},
|
||||||
|
{ group: 'Импорт', items: [
|
||||||
|
{ to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' },
|
||||||
|
]},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
|
|
|
||||||
146
src/food-market.web/src/pages/MoySkladImportPage.tsx
Normal file
146
src/food-market.web/src/pages/MoySkladImportPage.tsx
Normal file
|
|
@ -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<TestResponse>('/api/admin/moysklad/test', { token })).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
const run = useMutation({
|
||||||
|
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-products', { token, overwriteExisting: overwrite })).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-3xl">
|
||||||
|
<PageHeader
|
||||||
|
title="Импорт из МойСклад"
|
||||||
|
description="Перенос товаров, групп и цен из учётной записи МойСклад в food-market."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="mb-5 rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-900/50 p-3.5 text-sm">
|
||||||
|
<div className="flex gap-2.5 items-start">
|
||||||
|
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-amber-900 dark:text-amber-200 space-y-1.5">
|
||||||
|
<p><strong>Токен не сохраняется</strong> — передаётся только в текущий запрос и не пишется ни в БД, ни в логи.</p>
|
||||||
|
<p>Получить токен: <a className="underline" href="https://online.moysklad.ru/app/#admin" target="_blank" rel="noreferrer">online.moysklad.ru/app</a> → Настройки аккаунта → Сервисный аккаунт → создать токен (read-only прав достаточно).</p>
|
||||||
|
<p>Рекомендуется отдельный сервисный аккаунт с правом только «Просмотр: Товары».</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
|
||||||
|
<Field label="Токен МойСклад (Bearer)">
|
||||||
|
<TextInput
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder="персональный токен или токен сервисного аккаунта"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => test.mutate()}
|
||||||
|
disabled={!token || test.isPending}
|
||||||
|
>
|
||||||
|
<KeyRound className="w-4 h-4" />
|
||||||
|
{test.isPending ? 'Проверяю…' : 'Проверить соединение'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{test.data && (
|
||||||
|
<div className="text-sm text-green-700 dark:text-green-400 flex items-center gap-1.5">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Подключено: <strong>{test.data.organization}</strong>
|
||||||
|
{test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{test.error && (
|
||||||
|
<div className="text-sm text-red-600">
|
||||||
|
{(test.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-slate-200 dark:border-slate-800 space-y-3">
|
||||||
|
<Checkbox
|
||||||
|
label="Перезаписать существующие товары (по артикулу/штрихкоду)"
|
||||||
|
checked={overwrite}
|
||||||
|
onChange={setOverwrite}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => run.mutate()}
|
||||||
|
disabled={!token || run.isPending}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
{run.isPending ? 'Импортирую… (может занять минуты)' : 'Импортировать товары'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{run.data && (
|
||||||
|
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
|
||||||
|
<h3 className="text-sm font-semibold mb-3 text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600" /> Импорт завершён
|
||||||
|
</h3>
|
||||||
|
<dl className="grid grid-cols-4 gap-3 text-sm">
|
||||||
|
<div className="rounded-lg bg-slate-50 dark:bg-slate-800/50 p-3">
|
||||||
|
<dt className="text-xs text-slate-500 uppercase">Всего получено</dt>
|
||||||
|
<dd className="text-xl font-semibold mt-1">{run.data.total}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-green-50 dark:bg-green-950/30 p-3">
|
||||||
|
<dt className="text-xs text-green-700 dark:text-green-400 uppercase">Создано</dt>
|
||||||
|
<dd className="text-xl font-semibold mt-1 text-green-700 dark:text-green-400">{run.data.created}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-slate-50 dark:bg-slate-800/50 p-3">
|
||||||
|
<dt className="text-xs text-slate-500 uppercase">Пропущено</dt>
|
||||||
|
<dd className="text-xl font-semibold mt-1">{run.data.skipped}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-slate-50 dark:bg-slate-800/50 p-3">
|
||||||
|
<dt className="text-xs text-slate-500 uppercase">Групп создано</dt>
|
||||||
|
<dd className="text-xl font-semibold mt-1">{run.data.groupsCreated}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{run.data.errors.length > 0 && (
|
||||||
|
<details className="mt-4">
|
||||||
|
<summary className="text-sm text-red-600 cursor-pointer">
|
||||||
|
Ошибок: {run.data.errors.length} (развернуть)
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-2 text-xs font-mono bg-red-50 dark:bg-red-950/30 p-3 rounded space-y-0.5 max-h-80 overflow-auto">
|
||||||
|
{run.data.errors.map((e, i) => <li key={i}>{e}</li>)}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{run.error && (
|
||||||
|
<div className="mt-5 p-3.5 rounded-md bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900 text-sm text-red-700 dark:text-red-300">
|
||||||
|
Ошибка: {(run.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue