From 8346c9a72ee5203544d48a97404f59a8538f72d6 Mon Sep 17 00:00:00 2001 From: nurdotnet <278048682+nurdotnet@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:58:48 +0500 Subject: [PATCH] feat(admin): temp cleanup buttons + fix MoySklad import duplicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: при импорте контрагентов/товаров с галкой «перезаписать» код ставил 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 с двумя красными кнопками + подтверждение словом «УДАЛИТЬ». Показываются счётчики до и что удалилось после. --- .../Admin/AdminCleanupController.cs | 130 +++++++++++++ .../MoySklad/MoySkladImportService.cs | 176 +++++++++++------- .../src/pages/MoySkladImportPage.tsx | 134 ++++++++++++- 3 files changed, 367 insertions(+), 73 deletions(-) create mode 100644 src/food-market.api/Controllers/Admin/AdminCleanupController.cs diff --git a/src/food-market.api/Controllers/Admin/AdminCleanupController.cs b/src/food-market.api/Controllers/Admin/AdminCleanupController.cs new file mode 100644 index 0000000..600819a --- /dev/null +++ b/src/food-market.api/Controllers/Admin/AdminCleanupController.cs @@ -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> 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)); + + /// Удалить всех контрагентов текущей организации. Чтобы не нарваться на FK, + /// сначала обнуляем ссылки (Product.DefaultSupplier, RetailSale.Customer) и сносим + /// поставки (они жёстко ссылаются на supplier). + [HttpDelete("counterparties")] + public async Task> 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)); + } + + /// Полная очистка данных текущей организации — всё кроме настроек: + /// остаются Organization, пользователи, справочники (Country, Currency, UnitOfMeasure, + /// PriceType), Store и RetailPoint. Удаляются: Product*, ProductGroup, Counterparty, + /// Supply*, RetailSale*, Stock, StockMovement. + [HttpDelete("all")] + public async Task> 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 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); +} diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs index 51cb1f5..20ff3a9 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs @@ -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(); @@ -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 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 ImportProductsAsync( @@ -160,13 +174,14 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) // Import products var errors = new List(); 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(existingArticles, StringComparer.OrdinalIgnoreCase); + .ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct); var existingBarcodeSet = new HashSet( 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 ExtractBarcodes(MsProduct p) diff --git a/src/food-market.web/src/pages/MoySkladImportPage.tsx b/src/food-market.web/src/pages/MoySkladImportPage.tsx index 4f49f08..777975a 100644 --- a/src/food-market.web/src/pages/MoySkladImportPage.tsx +++ b/src/food-market.web/src/pages/MoySkladImportPage.tsx @@ -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() { + + ) } +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('/api/admin/cleanup/stats')).data, + refetchOnMount: 'always', + }) + + const wipeCounterparties = useMutation({ + mutationFn: async () => (await api.delete('/api/admin/cleanup/counterparties')).data, + onSuccess: () => { + qc.invalidateQueries() + }, + }) + + const wipeAll = useMutation({ + mutationFn: async () => (await api.delete('/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 ( +
+

+ Опасная зона — временные инструменты очистки +

+

+ Удаляет данные текущей организации. Справочники (ед. измерения, страны, валюты, типы цен, склады, точки продаж), пользователи и сама организация сохраняются. +

+ + {s && ( +
+ + + + + + + + + +
+ )} + +
+ + +
+ + {wipeCounterparties.data && ( +
+ Удалено: {wipeCounterparties.data.deleted.counterparties} контрагентов, + {' '}{wipeCounterparties.data.deleted.supplies} поставок, + {' '}{wipeCounterparties.data.deleted.stockMovements} движений. +
+ )} + {wipeAll.data && ( +
+ Удалено: {wipeAll.data.deleted.counterparties} контрагентов, + {' '}{wipeAll.data.deleted.products} товаров, + {' '}{wipeAll.data.deleted.productGroups} групп, + {' '}{wipeAll.data.deleted.supplies} поставок, + {' '}{wipeAll.data.deleted.retailSales} чеков, + {' '}{wipeAll.data.deleted.stockMovements} движений. +
+ )} + {wipeCounterparties.error && ( +
{formatError(wipeCounterparties.error)}
+ )} + {wipeAll.error && ( +
{formatError(wipeAll.error)}
+ )} +
+ ) +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value.toLocaleString('ru')}
+
+ ) +} + function ImportResult({ title, result }: { title: string; result: UseMutationResult }) { if (!result.data && !result.error) return null return (