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.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<SystemReferenceSeeder>();
|
||||
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 { 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() {
|
|||
<Route path="/catalog/retail-points" element={<RetailPointsPage />} />
|
||||
<Route path="/catalog/countries" element={<CountriesPage />} />
|
||||
<Route path="/catalog/currencies" element={<CurrenciesPage />} />
|
||||
<Route path="/admin/import/moysklad" element={<MoySkladImportPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
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