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
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:
parent
9891280bfd
commit
8346c9a72e
130
src/food-market.api/Controllers/Admin/AdminCleanupController.cs
Normal file
130
src/food-market.api/Controllers/Admin/AdminCleanupController.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
_db.Counterparties.Add(entity);
|
||||
existingByName[c.Name] = entity.Id;
|
||||
created++;
|
||||
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;
|
||||
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,48 +210,70 @@ 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)
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
Name = Trim(p.Name, 500),
|
||||
Article = Trim(article, 500),
|
||||
Description = p.Description,
|
||||
UnitOfMeasureId = baseUnit.Id,
|
||||
Vat = vat,
|
||||
VatEnabled = vatEnabled,
|
||||
ProductGroupId = groupId,
|
||||
CountryOfOriginId = countryId,
|
||||
IsWeighed = p.Weighed,
|
||||
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 = 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.Prices.Add(new ProductPrice
|
||||
product = new Product
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
PriceTypeId = retailType.Id,
|
||||
Amount = retailPrice.Value / 100m,
|
||||
CurrencyId = kzt.Id,
|
||||
});
|
||||
Name = Trim(p.Name, 500),
|
||||
Article = Trim(article, 500),
|
||||
Description = p.Description,
|
||||
UnitOfMeasureId = baseUnit.Id,
|
||||
Vat = vat,
|
||||
VatEnabled = vatEnabled,
|
||||
ProductGroupId = groupId,
|
||||
CountryOfOriginId = countryId,
|
||||
IsWeighed = p.Weighed,
|
||||
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)) existingByArticle[article] = product;
|
||||
created++;
|
||||
}
|
||||
|
||||
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);
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Reference in a new issue