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,
|
_ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Загружаем существующих в память — обновлять будем по имени (case-insensitive).
|
||||||
|
// Раньше при overwriteExisting=true бага: пропускалась проверка "skip" и ВСЕ
|
||||||
|
// поступающие записи добавлялись как новые, порождая дубли. Теперь если имя уже
|
||||||
|
// есть — обновляем ту же запись, иначе создаём.
|
||||||
var existingByName = await _db.Counterparties
|
var existingByName = await _db.Counterparties
|
||||||
.Select(c => new { c.Id, c.Name })
|
.ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct);
|
||||||
.ToDictionaryAsync(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase, ct);
|
|
||||||
|
|
||||||
var created = 0;
|
var created = 0;
|
||||||
|
var updated = 0;
|
||||||
var skipped = 0;
|
var skipped = 0;
|
||||||
var total = 0;
|
var total = 0;
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
|
|
@ -67,31 +71,23 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
total++;
|
total++;
|
||||||
if (c.Archived) { skipped++; continue; }
|
if (c.Archived) { skipped++; continue; }
|
||||||
|
|
||||||
if (existingByName.ContainsKey(c.Name) && !overwriteExisting)
|
|
||||||
{
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var entity = new foodmarket.Domain.Catalog.Counterparty
|
if (existingByName.TryGetValue(c.Name, out var existing))
|
||||||
{
|
{
|
||||||
OrganizationId = orgId,
|
if (!overwriteExisting) { skipped++; continue; }
|
||||||
Name = Trim(c.Name, 255) ?? c.Name,
|
ApplyCounterparty(existing, c, ResolveType);
|
||||||
LegalName = Trim(c.LegalTitle, 500),
|
updated++;
|
||||||
Type = ResolveType(c.CompanyType),
|
}
|
||||||
Bin = Trim(c.Inn, 20),
|
else
|
||||||
TaxNumber = Trim(c.Kpp, 20),
|
{
|
||||||
Phone = Trim(c.Phone, 50),
|
var entity = new foodmarket.Domain.Catalog.Counterparty { OrganizationId = orgId };
|
||||||
Email = Trim(c.Email, 255),
|
ApplyCounterparty(entity, c, ResolveType);
|
||||||
Address = Trim(c.ActualAddress ?? c.LegalAddress, 500),
|
|
||||||
Notes = Trim(c.Description, 1000),
|
|
||||||
IsActive = !c.Archived,
|
|
||||||
};
|
|
||||||
_db.Counterparties.Add(entity);
|
_db.Counterparties.Add(entity);
|
||||||
existingByName[c.Name] = entity.Id;
|
existingByName[c.Name] = entity;
|
||||||
created++;
|
created++;
|
||||||
|
}
|
||||||
|
|
||||||
batch++;
|
batch++;
|
||||||
if (batch >= 500)
|
if (batch >= 500)
|
||||||
{
|
{
|
||||||
|
|
@ -107,7 +103,25 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (batch > 0) await _db.SaveChangesAsync(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(
|
public async Task<MoySkladImportResult> ImportProductsAsync(
|
||||||
|
|
@ -160,13 +174,14 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
// Import products
|
// Import products
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
var created = 0;
|
var created = 0;
|
||||||
|
var updated = 0;
|
||||||
var skipped = 0;
|
var skipped = 0;
|
||||||
var total = 0;
|
var total = 0;
|
||||||
var existingArticles = await _db.Products
|
// При overwriteExisting=true загружаем товары целиком, чтобы обновлять существующие
|
||||||
|
// вместо создания дубликатов. Ключ = артикул (нормализованный).
|
||||||
|
var existingByArticle = await _db.Products
|
||||||
.Where(p => p.Article != null)
|
.Where(p => p.Article != null)
|
||||||
.Select(p => p.Article!)
|
.ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct);
|
||||||
.ToListAsync(ct);
|
|
||||||
var existingArticleSet = new HashSet<string>(existingArticles, StringComparer.OrdinalIgnoreCase);
|
|
||||||
var existingBarcodeSet = new HashSet<string>(
|
var existingBarcodeSet = new HashSet<string>(
|
||||||
await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct));
|
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; }
|
if (p.Archived) { skipped++; continue; }
|
||||||
|
|
||||||
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
|
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);
|
if (alreadyByArticle && !overwriteExisting)
|
||||||
var alreadyByBarcode = primaryBarcode is not null && existingBarcodeSet.Contains(primaryBarcode.Code);
|
|
||||||
|
|
||||||
if ((alreadyByArticle || alreadyByBarcode) && !overwriteExisting)
|
|
||||||
{
|
{
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
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)
|
var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
?? p.SalePrices?.FirstOrDefault();
|
?? 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,
|
OrganizationId = orgId,
|
||||||
Name = Trim(p.Name, 500),
|
Name = Trim(p.Name, 500),
|
||||||
|
|
@ -235,11 +268,12 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||||
}
|
}
|
||||||
|
|
||||||
_db.Products.Add(product);
|
_db.Products.Add(product);
|
||||||
if (!string.IsNullOrWhiteSpace(article)) existingArticleSet.Add(article);
|
if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product;
|
||||||
created++;
|
created++;
|
||||||
|
}
|
||||||
|
|
||||||
// Flush every 500 products to keep change tracker light.
|
// Flush periodically to keep change tracker light.
|
||||||
if (created % 500 == 0) await _db.SaveChangesAsync(ct);
|
if ((created + updated) % 500 == 0) await _db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -249,7 +283,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync(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)
|
private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
|
||||||
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package } from 'lucide-react'
|
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package, Trash2, AlertTriangle } from 'lucide-react'
|
||||||
import { AxiosError } from 'axios'
|
import { AxiosError } from 'axios'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
|
@ -122,11 +122,141 @@ export function MoySkladImportPage() {
|
||||||
|
|
||||||
<ImportResult title="Товары" result={products} />
|
<ImportResult title="Товары" result={products} />
|
||||||
<ImportResult title="Контрагенты" result={counterparties} />
|
<ImportResult title="Контрагенты" result={counterparties} />
|
||||||
|
|
||||||
|
<DangerZone />
|
||||||
</div>
|
</div>
|
||||||
</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> }) {
|
function ImportResult({ title, result }: { title: string; result: UseMutationResult<ImportResponse, Error, void, unknown> }) {
|
||||||
if (!result.data && !result.error) return null
|
if (!result.data && !result.error) return null
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue