feat(admin): temp cleanup buttons + fix MoySklad import duplicates
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 35s
CI / Web (React + Vite) (push) Successful in 23s
Docker Images / API image (push) Successful in 1m10s
Docker Images / Web image (push) Successful in 41s
Docker Images / Deploy stage (push) Successful in 18s

Проблема: при импорте контрагентов/товаров с галкой «перезаписать» код
ставил Add() новой сущности вместо Update() существующей, порождая
дубликаты. Исправил оба потока — теперь по ключу (Name для контрагентов,
Article для товаров) ищем существующую запись и обновляем её на месте.
Коллекции (цены/штрихкоды товара) при апдейте не трогаем, чтобы не
затереть ручные правки пользователя.

Временные админские кнопки для разбора последствий прошлых импортов:
- DELETE /api/admin/cleanup/counterparties — сносит контрагентов + зависимые поставки + их stock-movements (RetailSale.CustomerId обнуляется, Product.DefaultSupplierId обнуляется)
- DELETE /api/admin/cleanup/all — сносит всё tenant-scoped (товары/группы/контрагенты/поставки/чеки/остатки/движения). Организация, пользователи, справочники (единицы, страны, валюты, типы цен, склады, точки продаж) остаются.
- GET /api/admin/cleanup/stats — превью с количеством записей.

UI: секция «Опасная зона» внизу страницы /admin/import/moysklad с двумя
красными кнопками + подтверждение словом «УДАЛИТЬ». Показываются счётчики
до и что удалилось после.
This commit is contained in:
nurdotnet 2026-04-23 20:58:48 +05:00
parent 9891280bfd
commit 8346c9a72e
3 changed files with 367 additions and 73 deletions

View file

@ -0,0 +1,130 @@
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Admin;
// Временные эндпоинты для очистки данных после кривых импортов.
// Удалять только свой tenant — query-filter на DbSets это обеспечивает.
[ApiController]
[Authorize(Policy = "AdminAccess")]
[Route("api/admin/cleanup")]
public class AdminCleanupController : ControllerBase
{
private readonly AppDbContext _db;
public AdminCleanupController(AppDbContext db) => _db = db;
public record CleanupStats(
int Counterparties,
int Products,
int ProductGroups,
int ProductBarcodes,
int ProductPrices,
int Supplies,
int RetailSales,
int Stocks,
int StockMovements);
public record CleanupResult(string Scope, CleanupStats Deleted);
[HttpGet("stats")]
public async Task<ActionResult<CleanupStats>> GetStats(CancellationToken ct)
=> new CleanupStats(
await _db.Counterparties.CountAsync(ct),
await _db.Products.CountAsync(ct),
await _db.ProductGroups.CountAsync(ct),
await _db.ProductBarcodes.CountAsync(ct),
await _db.ProductPrices.CountAsync(ct),
await _db.Supplies.CountAsync(ct),
await _db.RetailSales.CountAsync(ct),
await _db.Stocks.CountAsync(ct),
await _db.StockMovements.CountAsync(ct));
/// <summary>Удалить всех контрагентов текущей организации. Чтобы не нарваться на FK,
/// сначала обнуляем ссылки (Product.DefaultSupplier, RetailSale.Customer) и сносим
/// поставки (они жёстко ссылаются на supplier).</summary>
[HttpDelete("counterparties")]
public async Task<ActionResult<CleanupResult>> WipeCounterparties(CancellationToken ct)
{
var before = await SnapshotAsync(ct);
// 1. Обнуляем nullable-FK
await _db.Products
.Where(p => p.DefaultSupplierId != null)
.ExecuteUpdateAsync(u => u.SetProperty(p => p.DefaultSupplierId, (Guid?)null), ct);
await _db.RetailSales
.Where(s => s.CustomerId != null)
.ExecuteUpdateAsync(u => u.SetProperty(s => s.CustomerId, (Guid?)null), ct);
// 2. Сносим поставки (NOT NULL supplier) + их stock movements/stocks
await _db.StockMovements
.Where(m => m.DocumentType == "supply" || m.DocumentType == "supply-reversal")
.ExecuteDeleteAsync(ct);
await _db.SupplyLines.ExecuteDeleteAsync(ct);
await _db.Supplies.ExecuteDeleteAsync(ct);
// 3. Контрагенты
await _db.Counterparties.ExecuteDeleteAsync(ct);
var after = await SnapshotAsync(ct);
return new CleanupResult("counterparties", Diff(before, after));
}
/// <summary>Полная очистка данных текущей организации — всё кроме настроек:
/// остаются Organization, пользователи, справочники (Country, Currency, UnitOfMeasure,
/// PriceType), Store и RetailPoint. Удаляются: Product*, ProductGroup, Counterparty,
/// Supply*, RetailSale*, Stock, StockMovement.</summary>
[HttpDelete("all")]
public async Task<ActionResult<CleanupResult>> WipeAll(CancellationToken ct)
{
var before = await SnapshotAsync(ct);
// Documents first — they reference products, counterparties, stores.
await _db.StockMovements.ExecuteDeleteAsync(ct);
await _db.Stocks.ExecuteDeleteAsync(ct);
await _db.SupplyLines.ExecuteDeleteAsync(ct);
await _db.Supplies.ExecuteDeleteAsync(ct);
await _db.RetailSaleLines.ExecuteDeleteAsync(ct);
await _db.RetailSales.ExecuteDeleteAsync(ct);
// Product composites.
await _db.ProductImages.ExecuteDeleteAsync(ct);
await _db.ProductPrices.ExecuteDeleteAsync(ct);
await _db.ProductBarcodes.ExecuteDeleteAsync(ct);
// Products reference counterparty.DefaultSupplier — FK Restrict, but we're about
// to delete products anyway, so order products → counterparties.
await _db.Products.ExecuteDeleteAsync(ct);
await _db.ProductGroups.ExecuteDeleteAsync(ct);
await _db.Counterparties.ExecuteDeleteAsync(ct);
var after = await SnapshotAsync(ct);
return new CleanupResult("all", Diff(before, after));
}
private async Task<CleanupStats> SnapshotAsync(CancellationToken ct) => new(
await _db.Counterparties.CountAsync(ct),
await _db.Products.CountAsync(ct),
await _db.ProductGroups.CountAsync(ct),
await _db.ProductBarcodes.CountAsync(ct),
await _db.ProductPrices.CountAsync(ct),
await _db.Supplies.CountAsync(ct),
await _db.RetailSales.CountAsync(ct),
await _db.Stocks.CountAsync(ct),
await _db.StockMovements.CountAsync(ct));
private static CleanupStats Diff(CleanupStats a, CleanupStats b) => new(
a.Counterparties - b.Counterparties,
a.Products - b.Products,
a.ProductGroups - b.ProductGroups,
a.ProductBarcodes - b.ProductBarcodes,
a.ProductPrices - b.ProductPrices,
a.Supplies - b.Supplies,
a.RetailSales - b.RetailSales,
a.Stocks - b.Stocks,
a.StockMovements - b.StockMovements);
}

View file

@ -52,11 +52,15 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
_ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
};
// Загружаем существующих в память — обновлять будем по имени (case-insensitive).
// Раньше при overwriteExisting=true бага: пропускалась проверка "skip" и ВСЕ
// поступающие записи добавлялись как новые, порождая дубли. Теперь если имя уже
// есть — обновляем ту же запись, иначе создаём.
var existingByName = await _db.Counterparties
.Select(c => new { c.Id, c.Name })
.ToDictionaryAsync(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase, ct);
.ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct);
var created = 0;
var updated = 0;
var skipped = 0;
var total = 0;
var errors = new List<string>();
@ -67,31 +71,23 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
total++;
if (c.Archived) { skipped++; continue; }
if (existingByName.ContainsKey(c.Name) && !overwriteExisting)
{
skipped++;
continue;
}
try
{
var entity = new foodmarket.Domain.Catalog.Counterparty
if (existingByName.TryGetValue(c.Name, out var existing))
{
OrganizationId = orgId,
Name = Trim(c.Name, 255) ?? c.Name,
LegalName = Trim(c.LegalTitle, 500),
Type = ResolveType(c.CompanyType),
Bin = Trim(c.Inn, 20),
TaxNumber = Trim(c.Kpp, 20),
Phone = Trim(c.Phone, 50),
Email = Trim(c.Email, 255),
Address = Trim(c.ActualAddress ?? c.LegalAddress, 500),
Notes = Trim(c.Description, 1000),
IsActive = !c.Archived,
};
if (!overwriteExisting) { skipped++; continue; }
ApplyCounterparty(existing, c, ResolveType);
updated++;
}
else
{
var entity = new foodmarket.Domain.Catalog.Counterparty { OrganizationId = orgId };
ApplyCounterparty(entity, c, ResolveType);
_db.Counterparties.Add(entity);
existingByName[c.Name] = entity.Id;
existingByName[c.Name] = entity;
created++;
}
batch++;
if (batch >= 500)
{
@ -107,7 +103,25 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
}
if (batch > 0) await _db.SaveChangesAsync(ct);
return new MoySkladImportResult(total, created, skipped, 0, errors);
// `created` в отчёте = вставки + апдейты (чтобы не ломать UI, который знает только Created).
return new MoySkladImportResult(total, created + updated, skipped, 0, errors);
}
private static void ApplyCounterparty(
foodmarket.Domain.Catalog.Counterparty entity,
MsCounterparty c,
Func<string?, foodmarket.Domain.Catalog.CounterpartyType> resolveType)
{
entity.Name = Trim(c.Name, 255) ?? c.Name;
entity.LegalName = Trim(c.LegalTitle, 500);
entity.Type = resolveType(c.CompanyType);
entity.Bin = Trim(c.Inn, 20);
entity.TaxNumber = Trim(c.Kpp, 20);
entity.Phone = Trim(c.Phone, 50);
entity.Email = Trim(c.Email, 255);
entity.Address = Trim(c.ActualAddress ?? c.LegalAddress, 500);
entity.Notes = Trim(c.Description, 1000);
entity.IsActive = !c.Archived;
}
public async Task<MoySkladImportResult> ImportProductsAsync(
@ -160,13 +174,14 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
// Import products
var errors = new List<string>();
var created = 0;
var updated = 0;
var skipped = 0;
var total = 0;
var existingArticles = await _db.Products
// При overwriteExisting=true загружаем товары целиком, чтобы обновлять существующие
// вместо создания дубликатов. Ключ = артикул (нормализованный).
var existingByArticle = await _db.Products
.Where(p => p.Article != null)
.Select(p => p.Article!)
.ToListAsync(ct);
var existingArticleSet = new HashSet<string>(existingArticles, StringComparer.OrdinalIgnoreCase);
.ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct);
var existingBarcodeSet = new HashSet<string>(
await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct));
@ -176,12 +191,9 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
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) && existingByArticle.ContainsKey(article);
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingArticleSet.Contains(article);
var alreadyByBarcode = primaryBarcode is not null && existingBarcodeSet.Contains(primaryBarcode.Code);
if ((alreadyByArticle || alreadyByBarcode) && !overwriteExisting)
if (alreadyByArticle && !overwriteExisting)
{
skipped++;
continue;
@ -198,7 +210,28 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true)
?? p.SalePrices?.FirstOrDefault();
var product = new Product
Product product;
if (alreadyByArticle && overwriteExisting)
{
product = existingByArticle[article!];
// Обновляем только скалярные поля — коллекции (prices, barcodes) оставляем:
// там могут быть данные, которые редактировал пользователь после импорта.
product.Name = Trim(p.Name, 500);
product.Article = Trim(article, 500);
product.Description = p.Description;
product.Vat = vat;
product.VatEnabled = vatEnabled;
product.ProductGroupId = groupId ?? product.ProductGroupId;
product.CountryOfOriginId = countryId ?? product.CountryOfOriginId;
product.IsWeighed = p.Weighed;
product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
product.IsActive = !p.Archived;
product.PurchasePrice = p.BuyPrice is null ? product.PurchasePrice : p.BuyPrice.Value / 100m;
updated++;
}
else
{
product = new Product
{
OrganizationId = orgId,
Name = Trim(p.Name, 500),
@ -235,11 +268,12 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
}
_db.Products.Add(product);
if (!string.IsNullOrWhiteSpace(article)) existingArticleSet.Add(article);
if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product;
created++;
}
// Flush every 500 products to keep change tracker light.
if (created % 500 == 0) await _db.SaveChangesAsync(ct);
// Flush periodically to keep change tracker light.
if ((created + updated) % 500 == 0) await _db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
@ -249,7 +283,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
}
await _db.SaveChangesAsync(ct);
return new MoySkladImportResult(total, created, skipped, groupsCreated, errors);
return new MoySkladImportResult(total, created + updated, skipped, groupsCreated, errors);
}
private static List<ProductBarcode> ExtractBarcodes(MsProduct p)

View file

@ -1,6 +1,6 @@
import { useState } from 'react'
import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package } from 'lucide-react'
import { useMutation, useQuery, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package, Trash2, AlertTriangle } from 'lucide-react'
import { AxiosError } from 'axios'
import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader'
@ -122,11 +122,141 @@ export function MoySkladImportPage() {
<ImportResult title="Товары" result={products} />
<ImportResult title="Контрагенты" result={counterparties} />
<DangerZone />
</div>
</div>
)
}
interface CleanupStats {
counterparties: number
products: number
productGroups: number
productBarcodes: number
productPrices: number
supplies: number
retailSales: number
stocks: number
stockMovements: number
}
interface CleanupResult { scope: string; deleted: CleanupStats }
function DangerZone() {
const qc = useQueryClient()
const stats = useQuery({
queryKey: ['/api/admin/cleanup/stats'],
queryFn: async () => (await api.get<CleanupStats>('/api/admin/cleanup/stats')).data,
refetchOnMount: 'always',
})
const wipeCounterparties = useMutation({
mutationFn: async () => (await api.delete<CleanupResult>('/api/admin/cleanup/counterparties')).data,
onSuccess: () => {
qc.invalidateQueries()
},
})
const wipeAll = useMutation({
mutationFn: async () => (await api.delete<CleanupResult>('/api/admin/cleanup/all')).data,
onSuccess: () => {
qc.invalidateQueries()
},
})
const confirmAndRun = (label: string, run: () => void) => {
const word = prompt(`Введи УДАЛИТЬ чтобы подтвердить: ${label}`)
if (word?.trim().toUpperCase() === 'УДАЛИТЬ') run()
}
const s = stats.data
return (
<section className="mt-8 rounded-xl border-2 border-red-200 dark:border-red-900/50 bg-red-50/40 dark:bg-red-950/20 p-5">
<h2 className="text-sm font-semibold text-red-900 dark:text-red-300 flex items-center gap-2 mb-1.5">
<AlertTriangle className="w-4 h-4" /> Опасная зона временные инструменты очистки
</h2>
<p className="text-xs text-red-900/80 dark:text-red-300/80 mb-4">
Удаляет данные текущей организации. Справочники (ед. измерения, страны, валюты, типы цен, склады, точки продаж), пользователи и сама организация сохраняются.
</p>
{s && (
<dl className="grid grid-cols-3 md:grid-cols-5 gap-2 text-xs mb-4">
<Stat label="Контрагенты" value={s.counterparties} />
<Stat label="Товары" value={s.products} />
<Stat label="Группы" value={s.productGroups} />
<Stat label="Штрихкоды" value={s.productBarcodes} />
<Stat label="Цены" value={s.productPrices} />
<Stat label="Поставки" value={s.supplies} />
<Stat label="Чеки" value={s.retailSales} />
<Stat label="Остатки" value={s.stocks} />
<Stat label="Движения" value={s.stockMovements} />
</dl>
)}
<div className="flex gap-3 flex-wrap">
<Button
variant="danger"
onClick={() => confirmAndRun(
`${s?.counterparties ?? '?'} контрагентов (+ связанные поставки/движения)`,
() => wipeCounterparties.mutate(),
)}
disabled={wipeCounterparties.isPending || !s || s.counterparties === 0}
>
<Trash2 className="w-4 h-4" />
{wipeCounterparties.isPending ? 'Удаляю…' : 'Удалить контрагентов'}
</Button>
<Button
variant="danger"
onClick={() => confirmAndRun(
'ВСЕ данные организации (товары, группы, контрагенты, документы, остатки)',
() => wipeAll.mutate(),
)}
disabled={wipeAll.isPending}
>
<Trash2 className="w-4 h-4" />
{wipeAll.isPending ? 'Удаляю всё…' : 'Очистить все данные'}
</Button>
</div>
{wipeCounterparties.data && (
<div className="mt-3 text-xs text-red-900 dark:text-red-300">
Удалено: {wipeCounterparties.data.deleted.counterparties} контрагентов,
{' '}{wipeCounterparties.data.deleted.supplies} поставок,
{' '}{wipeCounterparties.data.deleted.stockMovements} движений.
</div>
)}
{wipeAll.data && (
<div className="mt-3 text-xs text-red-900 dark:text-red-300">
Удалено: {wipeAll.data.deleted.counterparties} контрагентов,
{' '}{wipeAll.data.deleted.products} товаров,
{' '}{wipeAll.data.deleted.productGroups} групп,
{' '}{wipeAll.data.deleted.supplies} поставок,
{' '}{wipeAll.data.deleted.retailSales} чеков,
{' '}{wipeAll.data.deleted.stockMovements} движений.
</div>
)}
{wipeCounterparties.error && (
<div className="mt-3 text-xs text-red-700">{formatError(wipeCounterparties.error)}</div>
)}
{wipeAll.error && (
<div className="mt-3 text-xs text-red-700">{formatError(wipeAll.error)}</div>
)}
</section>
)
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded bg-white dark:bg-slate-900 border border-red-200 dark:border-red-900/50 px-2 py-1.5">
<dt className="text-[10px] uppercase text-slate-500">{label}</dt>
<dd className="font-mono font-semibold">{value.toLocaleString('ru')}</dd>
</div>
)
}
function ImportResult({ title, result }: { title: string; result: UseMutationResult<ImportResponse, Error, void, unknown> }) {
if (!result.data && !result.error) return null
return (