Compare commits
2 commits
17374c3943
...
929d0b6a38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929d0b6a38 | ||
|
|
864d492c93 |
204
docs/catalog-import-progress.md
Normal file
204
docs/catalog-import-progress.md
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
# Catalog import МойСклад → «Демо-магазин» (stage)
|
||||||
|
|
||||||
|
**Решение владельца.** Стенд закрыт basic-auth'ом на gateway от
|
||||||
|
интернета (внутренняя сеть 192.168.1.x — без пароля), поэтому
|
||||||
|
можно залить полный каталог включая закупочные цены.
|
||||||
|
|
||||||
|
**Токен МойСклада.** `/var/www/sklad/appsettings.Production.json`,
|
||||||
|
ключ `MoySklad:Token`, забирается через ssh + sudo на prod-vm. В
|
||||||
|
логи / коммиты / docs / отчёты токен **никогда** не попадает.
|
||||||
|
Передаётся только через env-переменную или stdin.
|
||||||
|
|
||||||
|
**Границы.** Не трогаем проект `sklad`, прод `admin.food-market.kz`,
|
||||||
|
`global.json`, `deploy/*/nginx`.
|
||||||
|
|
||||||
|
## Чек-лист
|
||||||
|
|
||||||
|
- [x] 1. Аудит текущего MoySklad-импорта + добор недостающих полей.
|
||||||
|
- [x] 2. Импорт в «Демо-магазин» на стейдже без дублей.
|
||||||
|
- [x] 3. Токен у организации стенда не сохранён.
|
||||||
|
- [x] 4. Отчёт: группы/товары + выборочная сверка 10 карточек.
|
||||||
|
- [x] 5. Playwright-проверка каталога изнутри сети + повторный запуск
|
||||||
|
импорта → 0 новых.
|
||||||
|
|
||||||
|
## Протокол выполнения
|
||||||
|
|
||||||
|
### 1. Аудит + добор
|
||||||
|
|
||||||
|
Было в `MoySkladImportService`:
|
||||||
|
- ✅ товары, группы, штрихкоды (ean13/ean8/code128/gtin/upca/upce),
|
||||||
|
цены продажи (первая «Розничная» или первая), закупочные
|
||||||
|
(`buyPrice` → `ReferencePrice`), весовой признак (`weighed`),
|
||||||
|
артикул (`article ?? code`).
|
||||||
|
- ❌ **upsert по внешнему id МойСклада** — был по article, что ломает
|
||||||
|
идемпотентность для товаров без article.
|
||||||
|
- ❌ **merge по штрихкоду с локальным дублем** — просто пропускал
|
||||||
|
с ошибкой «штрихкод занят».
|
||||||
|
- ❌ **skip архивных** — импортировал их как IsActive=false.
|
||||||
|
- ❌ **UOM-mapping** — всегда ставил «штука» (`code=796`).
|
||||||
|
- ❌ **auth-scheme** — только Bearer; sklad на prod-vm использует Basic.
|
||||||
|
- ⚠️ **фото** — у реального каталога (30 941 товар) `images.meta.size=0`
|
||||||
|
во всех выборках, фото в МойСкладе не загружены. Пропустили
|
||||||
|
как «нечего импортировать» (см. gaps ниже).
|
||||||
|
- ⚠️ **мин. остаток** — проверил MoySklad API /entity/product (keys:
|
||||||
|
`accountId, archived, barcodes, buyPrice, code, discountProhibited,
|
||||||
|
externalCode, files, group, id, images, isSerialTrackable, meta,
|
||||||
|
minPrice, name, owner, pathName, paymentItemType, productFolder,
|
||||||
|
salePrices, shared, supplier, trackingType, uom, updated,
|
||||||
|
useParentVat, variantsCount, volume, weight`): поля `minimumBalance`
|
||||||
|
или аналога у product нет. Остатки живут в report/stock, не в
|
||||||
|
карточке товара → импортировать нечего.
|
||||||
|
|
||||||
|
Добавлено (**Phase32a_MoySkladExternalId**):
|
||||||
|
- `products.MoySkladExternalId varchar(64)` + partial-unique index
|
||||||
|
`(OrgId, MoySkladExternalId) WHERE IS NOT NULL`.
|
||||||
|
- `product_groups.MoySkladExternalId varchar(64)` + аналогичный индекс.
|
||||||
|
- `MoySkladClient`: auto-detect Basic vs Bearer по форме токена;
|
||||||
|
`StreamProductsAsync(includeArchived=false)` при `SkipArchived=true`;
|
||||||
|
`GetAllUomsAsync` для построения карты MS uom.id → MS code → наш code.
|
||||||
|
- `MoySkladImportService.ImportProductsAsync(options)`:
|
||||||
|
- приоритет resolution: MS id → штрихкод → article;
|
||||||
|
- `SkipArchived=true` → архивные товары в MoySklad не попадают в
|
||||||
|
поток;
|
||||||
|
- `MergeByBarcode=true` → локальный товар с таким же штрихкодом
|
||||||
|
апдейтится, не плодится вторым;
|
||||||
|
- дедуп штрихкодов внутри входа: первый выигрывает, остальные —
|
||||||
|
в `Errors` + счётчик `SkippedDuplicateBarcodeInSource`;
|
||||||
|
- UOM: MS uom.id → MS code → наш code; weighed=true → всегда «кг».
|
||||||
|
- `MoySkladImportResult` расширен: `MergedByBarcode`,
|
||||||
|
`SkippedArchivedInSource`, `SkippedDuplicateBarcodeInSource`,
|
||||||
|
`GroupsUpdated`.
|
||||||
|
- `MoySkladImportController.ImportRequest` — `SkipArchived` и
|
||||||
|
`MergeByBarcode` (оба default true), токен всё ещё принимается в
|
||||||
|
body и НЕ сохраняется в `Organizations.MoySkladToken`.
|
||||||
|
|
||||||
|
### 2. Импорт в «Демо-магазин»
|
||||||
|
|
||||||
|
Токен МойСклада получен безопасно:
|
||||||
|
```
|
||||||
|
ssh nns@192.168.1.190 "sudo cat /var/www/sklad/appsettings.Production.json" \
|
||||||
|
| python3 -c '…MoySklad.Token…' > /tmp/ms-token (0600)
|
||||||
|
```
|
||||||
|
В аутентификации — Basic (sklad хранит base64(login:password)), что
|
||||||
|
auto-detect на нашей стороне подхватил.
|
||||||
|
|
||||||
|
Деплой: `~/deploy-stage.sh` (api + web → test.admin.food-market.kz).
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
```
|
||||||
|
POST /api/admin/moysklad/import-products
|
||||||
|
Authorization: Bearer <demo-admin JWT>
|
||||||
|
{ "Token": "***", "OverwriteExisting": true,
|
||||||
|
"SkipArchived": true, "MergeByBarcode": true }
|
||||||
|
→ { "jobId": "8bb5b13e-c675-402d-a30e-edf26b8848f7" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Первый прогон — **376 секунд (6 мин 16 сек)**:
|
||||||
|
|
||||||
|
| Метрика | Значение |
|
||||||
|
|---|---|
|
||||||
|
| total (все активные в MoySklad) | 30 941 |
|
||||||
|
| created | 30 894 |
|
||||||
|
| merge-barcode (в локальный существующий) | 1 |
|
||||||
|
| skipped (без merge) | 0 |
|
||||||
|
| архивных в источнике пропущено | 0 (35 архивных отфильтрованы на уровне API-stream) |
|
||||||
|
| дубль-штрихкод внутри MoySklad | **46** (имена → `errors[]`) |
|
||||||
|
| группы created | 51 |
|
||||||
|
| группы updated | 1 (default «Продукты питания» → привязан MS id после совпадения по имени) |
|
||||||
|
|
||||||
|
### 3. Токен не сохранён
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
```
|
||||||
|
SELECT COALESCE("MoySkladToken" IS NOT NULL, false)
|
||||||
|
FROM organizations WHERE "Id" = 'b7c403fe-7fc8-48af-9490-4a9a2eafe2c2';
|
||||||
|
→ f
|
||||||
|
```
|
||||||
|
`Organizations.MoySkladToken IS NULL` для «Демо-магазин». Токен был
|
||||||
|
только в теле `POST /api/admin/moysklad/import-products`, в БД не
|
||||||
|
записан. Локальный `/tmp/ms-token` после импорта `shred -u`.
|
||||||
|
|
||||||
|
### 4. Отчёт
|
||||||
|
|
||||||
|
**По БД после первого импорта:**
|
||||||
|
```
|
||||||
|
products.count where OrgId=demo = 30 894
|
||||||
|
product_groups.count where OrgId=demo = 53 (51 из MS + 1 updated + 1 default system)
|
||||||
|
product_barcodes.count where OrgId=demo = 30 907
|
||||||
|
distinct barcode codes = 30 907
|
||||||
|
duplicate rows = 0 ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
**Второй прогон импорта (идемпотентность) — 362 секунды:**
|
||||||
|
|
||||||
|
| Метрика | Значение |
|
||||||
|
|---|---|
|
||||||
|
| total | 30 941 |
|
||||||
|
| created | **0** ✓ |
|
||||||
|
| updated | 30 894 (все существующие refresh'нуты) |
|
||||||
|
| merge-barcode | 0 |
|
||||||
|
| groupsCreated | 0 |
|
||||||
|
| groupsUpdated | 52 |
|
||||||
|
|
||||||
|
Второй прогон не создал ни одной новой записи — upsert по MS id
|
||||||
|
работает как положено.
|
||||||
|
|
||||||
|
**Выборочная сверка 10 товаров** (случайный sample по БД → MoySklad
|
||||||
|
API по тому же MS id, все совпадают 1:1):
|
||||||
|
|
||||||
|
| MS id | local name | MS name | штрихкоды | розничная (local → MS) | закупочная (local → MS) |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `47c17be8…` | пошехонский 350гр | пошехонский 350гр | ✓ `4820039807373` | ✓ 2540 → 2540 | ✓ 2030 → 2030 |
|
||||||
|
| `509f4a29…` | САРДИНЕЛЛА С Д/М 240ГР | САРДИНЕЛЛА С Д/М 240ГР | ✓ `4620004410894` | ✓ 640 → 640 | ✓ 512 → 512 |
|
||||||
|
| `982113cc…` | бьюти-блендер фрукты груша | бьюти-блендер фрукты груша | ✓ `6978461215094` | ✓ 470 → 470 | ✓ 300 → 300 |
|
||||||
|
| `785f457f…` | Печенье овсяное протеиновое «флэпджек» ш | Печенье овсяное протеиновое «флэпджек» ш | ✓ `4607018913849` | ✓ 455 → 455 | ✓ 350 → 350 |
|
||||||
|
| `637dcfee…` | Паста для шугаринга плотная 300гр | Паста для шугаринга плотная 300гр | ✓ `4870219750029` | ✓ 1050 → 1050 | ✓ 700 → 700 |
|
||||||
|
| `ee17df53…` | шоколад Альпен Гольд черника-йогуртовая | шоколад Альпен Гольд черника-йогуртовая | ✓ `7622201426804` | ✓ 595 → 595 | ✓ 349 → 349 |
|
||||||
|
| `2a132fa3…` | Пастила детская клубника и яблоко | Пастила детская клубника и яблоко | ✓ `4607942730734` | ✓ 225 → 225 | ✓ 150 → 150 |
|
||||||
|
| `6f8b9c68…` | Монастырский чай №26 против паразитов | Монастырский чай №26 против паразитов | ✓ `4631142248812` | ✓ 1850 → 1850 | ✓ 1400 → 1400 |
|
||||||
|
| `36f5aa58…` | Лейс стакс паприка | Лейс стакс паприка | ✓ `4690388108946` | ✓ 1565 → 1565 | ✓ 1250 → 1250 |
|
||||||
|
| `eddfb85b…` | Айран домашний 0,5л | Айран домашний 0,5л | ✓ `7915178548083` | ✓ 590 → 590 | ✓ 490 → 490 |
|
||||||
|
|
||||||
|
Соответствие полное (10/10) по имени, штрихкоду, цене продажи,
|
||||||
|
закупочной цене.
|
||||||
|
|
||||||
|
### 5. Playwright UI
|
||||||
|
|
||||||
|
`tests/e2e/scenarios/stage-ui-catalog-import.spec.ts` — 1 тест,
|
||||||
|
`chromium-desktop`, против `https://test.admin.food-market.kz`:
|
||||||
|
1. Логин как `demo-admin@food-market.local` (sentinel).
|
||||||
|
2. Открыть `/catalog/products` → в шапке «30 894 записей» ≥ 30000 ✓.
|
||||||
|
3. Поиск по частичному названию «шоколад» → ≥ 1 строка ✓.
|
||||||
|
4. Поиск по штрихкоду `4820039807373` → ≥ 1 строка ✓.
|
||||||
|
5. Клик по строке → карточка товара → простой режим: «Название»,
|
||||||
|
«Штрихкоды», «Группа», «Единица измерения», «Розничная цена» видны ✓.
|
||||||
|
6. В sidebar под простым режимом нет «Типы цен» ✓.
|
||||||
|
|
||||||
|
Прогон: `1 passed (9.6s)`. Скриншоты в `tests/e2e/reports/`:
|
||||||
|
`catalog-import-list.png`, `catalog-import-search-name.png`,
|
||||||
|
`catalog-import-search-barcode.png`, `catalog-import-product-card.png`.
|
||||||
|
|
||||||
|
## Gaps / known-not-implemented
|
||||||
|
|
||||||
|
- **Фото товаров**: сам MoySklad в этом аккаунте images не хранит
|
||||||
|
(все продукты, что проверяли, — `images.meta.size = 0`). Download
|
||||||
|
+upload в наш storage не реализовали — нет источника. Если
|
||||||
|
в будущем у клиентов будут картинки, нужно будет добавить:
|
||||||
|
`/entity/product/{id}/images` → GET `downloadHref` с токеном →
|
||||||
|
`IObjectStorage.SaveAsync` → `ProductImage`.
|
||||||
|
- **Минимальный остаток (`minStock`)**: MoySklad не передаёт на
|
||||||
|
уровне `/entity/product`. Нужно использовать `report/stock/all` или
|
||||||
|
per-store `storeassortment` — отдельная история, выпал за scope.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
- Каталог «Демо-магазин» на `https://test.admin.food-market.kz`
|
||||||
|
содержит **30 894 товара** из МойСклада, **53 группы**,
|
||||||
|
**30 907 штрихкодов** (0 дублей), все с привязанным
|
||||||
|
`MoySkladExternalId`. Токен у организации не хранится.
|
||||||
|
- Повторный запуск импорта (с тем же токеном в body) ничего не
|
||||||
|
создаёт: `created=0`, только обновляет существующие записи. 46
|
||||||
|
товаров с дубль-штрихкодом внутри самого МойСклада пропущены с
|
||||||
|
записью в `errors[]`.
|
||||||
|
- Прод, `global.json`, `deploy/*/nginx` не трогались.
|
||||||
|
|
||||||
|
|
@ -34,7 +34,11 @@ public class MoySkladImportController : ControllerBase
|
||||||
}
|
}
|
||||||
|
|
||||||
public record TestRequest(string? Token = null);
|
public record TestRequest(string? Token = null);
|
||||||
public record ImportRequest(string? Token = null, bool OverwriteExisting = false);
|
public record ImportRequest(
|
||||||
|
string? Token = null,
|
||||||
|
bool OverwriteExisting = false,
|
||||||
|
bool SkipArchived = true,
|
||||||
|
bool MergeByBarcode = true);
|
||||||
public record SettingsDto(bool HasToken, string? Masked);
|
public record SettingsDto(bool HasToken, string? Masked);
|
||||||
public record SettingsInput(string Token);
|
public record SettingsInput(string Token);
|
||||||
|
|
||||||
|
|
@ -89,11 +93,25 @@ public async Task<ActionResult<object>> ImportProducts([FromBody] ImportRequest
|
||||||
|
|
||||||
var job = _jobs.Create("products");
|
var job = _jobs.Create("products");
|
||||||
job.Stage = "Подключение к MoySklad…";
|
job.Stage = "Подключение к MoySklad…";
|
||||||
|
var options = new MoySkladImportOptions
|
||||||
|
{
|
||||||
|
OverwriteExisting = req.OverwriteExisting,
|
||||||
|
SkipArchived = req.SkipArchived,
|
||||||
|
MergeByBarcode = req.MergeByBarcode,
|
||||||
|
};
|
||||||
_ = RunInBackgroundAsync(job, async (svc, progress, ctInner) =>
|
_ = RunInBackgroundAsync(job, async (svc, progress, ctInner) =>
|
||||||
{
|
{
|
||||||
progress.Stage = "Импорт товаров…";
|
progress.Stage = "Импорт товаров…";
|
||||||
var result = await svc.ImportProductsAsync(token, req.OverwriteExisting, ctInner, progress);
|
var result = await svc.ImportProductsAsync(token, options, ctInner, progress);
|
||||||
progress.Message = $"Готово: {result.Created} записей (создано/обновлено), {result.Skipped} пропущено, {result.GroupsCreated} групп.";
|
// Sprint 32: расширенный summary — отдельно merge/skip-archived/skip-dupe.
|
||||||
|
progress.Message =
|
||||||
|
$"Готово: создано {result.Created - result.MergedByBarcode}," +
|
||||||
|
$" merge-barcode {result.MergedByBarcode}," +
|
||||||
|
$" пропущено {result.Skipped}," +
|
||||||
|
$" архивных пропущено {result.SkippedArchivedInSource}," +
|
||||||
|
$" дубль-штрихкод в MS {result.SkippedDuplicateBarcodeInSource}," +
|
||||||
|
$" групп создано {result.GroupsCreated}," +
|
||||||
|
$" групп обновлено {result.GroupsUpdated}.";
|
||||||
}, orgId);
|
}, orgId);
|
||||||
return Ok(new { jobId = job.Id });
|
return Ok(new { jobId = job.Id });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,12 @@ public class Product : TenantEntity
|
||||||
|
|
||||||
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
|
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
|
||||||
|
|
||||||
|
/// <summary>Внешний id сущности в MoySklad (UUID). Используется для
|
||||||
|
/// идемпотентного upsert'а при повторных импортах — повторный прогон
|
||||||
|
/// находит ту же запись и обновляет её, а не создаёт дубликат.
|
||||||
|
/// NULL для товаров, созданных пользователем вручную.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
|
|
||||||
/// <summary>Архивный товар. Скрывается из обычных списков (List default
|
/// <summary>Архивный товар. Скрывается из обычных списков (List default
|
||||||
/// filter); попадает в отчёты по историческим данным, но не предлагается
|
/// filter); попадает в отчёты по историческим данным, но не предлагается
|
||||||
/// в новых документах. Sprint 19: bulk-операция «Архивировать».</summary>
|
/// в новых документах. Sprint 19: bulk-операция «Архивировать».</summary>
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,10 @@ public class ProductGroup : Entity, IOptionalTenantEntity
|
||||||
/// предварительной настройки. Удалить или переименовать нельзя — иначе
|
/// предварительной настройки. Удалить или переименовать нельзя — иначе
|
||||||
/// продукты осиротеют.</summary>
|
/// продукты осиротеют.</summary>
|
||||||
public bool IsSystem { get; set; }
|
public bool IsSystem { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Внешний id группы в MoySklad (UUID productfolder). Нужен,
|
||||||
|
/// чтобы повторный импорт находил ту же группу (а не создавал
|
||||||
|
/// одноимённую «Молочные продукты №2»). NULL для групп, созданных
|
||||||
|
/// SuperAdmin'ом или пользователем вручную.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,15 @@ public MoySkladClient(HttpClient http)
|
||||||
private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string token)
|
private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string token)
|
||||||
{
|
{
|
||||||
var req = new HttpRequestMessage(method, pathAndQuery);
|
var req = new HttpRequestMessage(method, pathAndQuery);
|
||||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
// МойСклад принимает оба варианта аутентификации:
|
||||||
|
// - Basic <base64(login:password)> — пароль пользователя кабинета
|
||||||
|
// - Bearer <access_token> — токен из «Сервис → Настройки → API»
|
||||||
|
// Авто-детект: если токен выглядит как base64 (один раз распарсился
|
||||||
|
// в login:password) — используем Basic, иначе Bearer. У сторонних
|
||||||
|
// проектов (sklad на prod-vm) токен хранится в Basic-формате, у
|
||||||
|
// новых users чаще bearer-токен из UI.
|
||||||
|
var scheme = LooksLikeBasicB64(token) ? "Basic" : "Bearer";
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue(scheme, token);
|
||||||
// MoySklad requires the exact literal "application/json;charset=utf-8" (no space
|
// MoySklad requires the exact literal "application/json;charset=utf-8" (no space
|
||||||
// after ';'). The typed MediaTypeWithQualityHeaderValue API normalizes to
|
// after ';'). The typed MediaTypeWithQualityHeaderValue API normalizes to
|
||||||
// "application/json; charset=utf-8" which MoySklad rejects with code 1062.
|
// "application/json; charset=utf-8" which MoySklad rejects with code 1062.
|
||||||
|
|
@ -69,12 +77,21 @@ public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, C
|
||||||
|
|
||||||
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
|
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
|
||||||
string token,
|
string token,
|
||||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct,
|
||||||
|
bool includeArchived = true)
|
||||||
{
|
{
|
||||||
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: false, ct)) yield return p;
|
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: false, ct)) yield return p;
|
||||||
|
if (!includeArchived) yield break;
|
||||||
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: true, ct)) yield return p;
|
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: true, ct)) yield return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<MsUom>> GetAllUomsAsync(string token, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var all = new List<MsUom>();
|
||||||
|
await foreach (var u in StreamPagedAsync<MsUom>(token, "entity/uom", archivedOnly: false, ct)) all.Add(u);
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<MsCounterparty> StreamCounterpartiesAsync(
|
public async IAsyncEnumerable<MsCounterparty> StreamCounterpartiesAsync(
|
||||||
string token,
|
string token,
|
||||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
||||||
|
|
@ -83,6 +100,33 @@ public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, C
|
||||||
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: true, ct)) yield return c;
|
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: true, ct)) yield return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Base64(login:password) выглядит как base64url-ish строка
|
||||||
|
/// без пробелов, при декодировании содержит ':' между логином и
|
||||||
|
/// паролем. У bearer'ов этот паттерн тоже встречается, но в них нет
|
||||||
|
/// base64 padding и декод в login:password обычно даёт мусор.</summary>
|
||||||
|
private static bool LooksLikeBasicB64(string token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return false;
|
||||||
|
// Длина 36 чаще всего — base64(login:password) ~ 24..40 символов.
|
||||||
|
// Проверяем валидный base64 + наличие ':' после декода.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var padded = token;
|
||||||
|
// Padding, если потеряли.
|
||||||
|
var remainder = padded.Length % 4;
|
||||||
|
if (remainder == 2) padded += "==";
|
||||||
|
else if (remainder == 3) padded += "=";
|
||||||
|
else if (remainder == 1) return false;
|
||||||
|
var raw = Convert.FromBase64String(padded);
|
||||||
|
var text = System.Text.Encoding.UTF8.GetString(raw);
|
||||||
|
return text.Contains(':') && !text.Any(c => c is < (char)0x20 and not '\t');
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
|
public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var all = new List<MsProductFolder>();
|
var all = new List<MsProductFolder>();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,37 @@ public record MoySkladImportResult(
|
||||||
int Created,
|
int Created,
|
||||||
int Skipped,
|
int Skipped,
|
||||||
int GroupsCreated,
|
int GroupsCreated,
|
||||||
IReadOnlyList<string> Errors);
|
IReadOnlyList<string> Errors)
|
||||||
|
{
|
||||||
|
/// <summary>Sprint 32: товары, которые уже существовали локально с
|
||||||
|
/// тем же штрихкодом — merge-ились в них, а не создавали дубль.
|
||||||
|
/// Входит в <see cref="Created"/> как часть «записей (создано/обновлено)».</summary>
|
||||||
|
public int MergedByBarcode { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Sprint 32: товары МойСклада, которые пропущены как
|
||||||
|
/// archived=true. С флагом <c>skipArchived=false</c> остаётся 0.</summary>
|
||||||
|
public int SkippedArchivedInSource { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Sprint 32: штрихкоды, встреченные в самом МойСкладе
|
||||||
|
/// у нескольких товаров — импортируем первый, остальные пропускаем
|
||||||
|
/// (их имена и штрихкоды — в <see cref="Errors"/>).</summary>
|
||||||
|
public int SkippedDuplicateBarcodeInSource { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Sprint 32: групп обновлено (MS id совпал с существующей).</summary>
|
||||||
|
public int GroupsUpdated { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MoySkladImportOptions
|
||||||
|
{
|
||||||
|
public bool OverwriteExisting { get; init; } = true;
|
||||||
|
/// <summary>Товары из архива MoySklad не импортировать совсем.
|
||||||
|
/// Соответствует требованию «архивные не льём».</summary>
|
||||||
|
public bool SkipArchived { get; init; } = true;
|
||||||
|
/// <summary>Если локально уже есть товар с таким же штрихкодом (а в
|
||||||
|
/// MoySklad он с другим external id) — обновлять его как merge, а
|
||||||
|
/// не создавать второй.</summary>
|
||||||
|
public bool MergeByBarcode { get; init; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
public class MoySkladImportService
|
public class MoySkladImportService
|
||||||
{
|
{
|
||||||
|
|
@ -45,12 +75,6 @@ public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token,
|
||||||
var orgId = organizationIdOverride ?? _tenant.OrganizationId
|
var orgId = organizationIdOverride ?? _tenant.OrganizationId
|
||||||
?? throw new InvalidOperationException("No tenant organization in context.");
|
?? throw new InvalidOperationException("No tenant organization in context.");
|
||||||
|
|
||||||
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
|
|
||||||
// counterparty entity содержит только group (группа доступа), tags
|
|
||||||
// (произвольные), state (пользовательская цепочка статусов), companyType
|
|
||||||
// (legal/individual/entrepreneur). Никакого role/kind. Поэтому у нас тоже
|
|
||||||
// этого поля нет — пусть пользователь сам решит.
|
|
||||||
|
|
||||||
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
|
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
|
||||||
=> companyType switch
|
=> companyType switch
|
||||||
{
|
{
|
||||||
|
|
@ -58,10 +82,6 @@ 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
|
||||||
.ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct);
|
.ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct);
|
||||||
|
|
||||||
|
|
@ -76,7 +96,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
{
|
{
|
||||||
total++;
|
total++;
|
||||||
if (progress is not null) progress.Total = total;
|
if (progress is not null) progress.Total = total;
|
||||||
// Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty).
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -113,7 +132,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (batch > 0) await _db.SaveChangesAsync(ct);
|
if (batch > 0) await _db.SaveChangesAsync(ct);
|
||||||
// `created` в отчёте = вставки + апдейты (чтобы не ломать UI, который знает только Created).
|
|
||||||
return new MoySkladImportResult(total, created + updated, skipped, 0, errors);
|
return new MoySkladImportResult(total, created + updated, skipped, 0, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,33 +151,59 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
entity.Notes = Trim(c.Description, 1000);
|
entity.Notes = Trim(c.Description, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MoySkladImportResult> ImportProductsAsync(
|
/// <summary>Backward-compat обёртка для старого API /admin/moysklad/import-products.
|
||||||
|
/// Делегирует к расширенной версии с дефолтными опциями.</summary>
|
||||||
|
public Task<MoySkladImportResult> ImportProductsAsync(
|
||||||
string token,
|
string token,
|
||||||
bool overwriteExisting,
|
bool overwriteExisting,
|
||||||
CancellationToken ct,
|
CancellationToken ct,
|
||||||
ImportJobProgress? progress = null,
|
ImportJobProgress? progress = null,
|
||||||
Guid? organizationIdOverride = null)
|
Guid? organizationIdOverride = null)
|
||||||
|
=> ImportProductsAsync(
|
||||||
|
token,
|
||||||
|
new MoySkladImportOptions { OverwriteExisting = overwriteExisting },
|
||||||
|
ct, progress, organizationIdOverride);
|
||||||
|
|
||||||
|
public async Task<MoySkladImportResult> ImportProductsAsync(
|
||||||
|
string token,
|
||||||
|
MoySkladImportOptions options,
|
||||||
|
CancellationToken ct,
|
||||||
|
ImportJobProgress? progress = null,
|
||||||
|
Guid? organizationIdOverride = null)
|
||||||
{
|
{
|
||||||
var orgId = organizationIdOverride ?? _tenant.OrganizationId
|
var orgId = organizationIdOverride ?? _tenant.OrganizationId
|
||||||
?? throw new InvalidOperationException("No tenant organization in context.");
|
?? throw new InvalidOperationException("No tenant organization in context.");
|
||||||
|
|
||||||
// Дефолт VAT — из страны организации (Country.VatRate), decimal(5,2).
|
// ------------------------------------------------------------------
|
||||||
|
// Справочники — грузим один раз.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
var defaultVat = await _db.Countries
|
var defaultVat = await _db.Countries
|
||||||
.Where(c => c.Code == (_db.Organizations
|
.Where(c => c.Code == (_db.Organizations
|
||||||
.Where(o => o.Id == orgId).Select(o => o.CountryCode).First()))
|
.Where(o => o.Id == orgId).Select(o => o.CountryCode).First()))
|
||||||
.Select(c => (decimal?)c.VatRate).FirstOrDefaultAsync(ct) ?? 16m;
|
.Select(c => (decimal?)c.VatRate).FirstOrDefaultAsync(ct) ?? 16m;
|
||||||
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct)
|
|
||||||
?? await _db.UnitsOfMeasure.FirstAsync(ct);
|
var localUomsByCode = await _db.UnitsOfMeasure
|
||||||
|
.ToDictionaryAsync(u => u.Code, u => u.Id, StringComparer.OrdinalIgnoreCase, ct);
|
||||||
|
if (!localUomsByCode.TryGetValue("796", out var pieceUomId))
|
||||||
|
pieceUomId = (await _db.UnitsOfMeasure.FirstAsync(ct)).Id;
|
||||||
|
if (!localUomsByCode.TryGetValue("166", out var kgUomId))
|
||||||
|
kgUomId = pieceUomId; // fallback
|
||||||
|
|
||||||
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
|
var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
|
||||||
?? await _db.PriceTypes.FirstAsync(ct);
|
?? await _db.PriceTypes.FirstAsync(ct);
|
||||||
var kzt = await _db.Currencies.FirstAsync(c => c.Code == "KZT", ct);
|
var kzt = await _db.Currencies.FirstAsync(c => c.Code == "KZT", ct);
|
||||||
|
|
||||||
var countriesByName = await _db.Countries
|
var countriesByName = await _db.Countries
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.ToDictionaryAsync(c => c.Name, c => c.Id, ct);
|
.ToDictionaryAsync(c => c.Name, c => c.Id, ct);
|
||||||
|
|
||||||
// Дефолтная группа на случай, когда у товара в MoySklad нет productFolder.
|
// MS UOM id → MS UOM code (чтобы потом смапить в наш UOM по code).
|
||||||
var defaultGroup = await _db.ProductGroups.FirstOrDefaultAsync(g => g.Name == "Продукты питания", ct);
|
var msUomCodeById = (await _client.GetAllUomsAsync(token, ct))
|
||||||
|
.Where(u => !string.IsNullOrEmpty(u.Id) && !string.IsNullOrEmpty(u.Code))
|
||||||
|
.ToDictionary(u => u.Id!, u => u.Code!);
|
||||||
|
|
||||||
|
// Дефолтная группа для товаров без folder'а в MoySklad.
|
||||||
|
var defaultGroup = await _db.ProductGroups.FirstOrDefaultAsync(
|
||||||
|
g => g.OrganizationId == orgId && g.Name == "Продукты питания", ct);
|
||||||
if (defaultGroup is null)
|
if (defaultGroup is null)
|
||||||
{
|
{
|
||||||
defaultGroup = new ProductGroup
|
defaultGroup = new ProductGroup
|
||||||
|
|
@ -173,113 +217,254 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
}
|
}
|
||||||
var defaultGroupId = defaultGroup.Id;
|
var defaultGroupId = defaultGroup.Id;
|
||||||
|
|
||||||
// Import folders first — build flat then link parents. Архивные тоже берём,
|
// ------------------------------------------------------------------
|
||||||
// помечаем IsActive=false — у MoySklad у productfolder есть archived.
|
// Группы — импорт/upsert по MoySkladExternalId.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
var folders = await _client.GetAllFoldersAsync(token, ct);
|
var folders = await _client.GetAllFoldersAsync(token, ct);
|
||||||
var localGroupByMsId = new Dictionary<string, Guid>();
|
var localGroupByMsId = new Dictionary<string, Guid>();
|
||||||
var groupsCreated = 0;
|
var groupsCreated = 0;
|
||||||
|
var groupsUpdated = 0;
|
||||||
|
|
||||||
|
var existingGroupsByMsId = await _db.ProductGroups
|
||||||
|
.Where(g => g.OrganizationId == orgId && g.MoySkladExternalId != null)
|
||||||
|
.ToDictionaryAsync(g => g.MoySkladExternalId!, g => g, ct);
|
||||||
|
|
||||||
|
// Для бэкона-ситуации (миграция с не-MS id'шных групп) — также
|
||||||
|
// индексируем по (ParentId, Name): если такая запись есть, подвязываем
|
||||||
|
// к ней MS id, не создавая вторую.
|
||||||
|
var existingGroupsByParentAndName = await _db.ProductGroups
|
||||||
|
.Where(g => g.OrganizationId == orgId)
|
||||||
|
.ToDictionaryAsync(
|
||||||
|
g => (g.ParentId, g.Name.ToLower()),
|
||||||
|
g => g, ct);
|
||||||
|
|
||||||
foreach (var f in folders.OrderBy(f => f.PathName?.Length ?? 0))
|
foreach (var f in folders.OrderBy(f => f.PathName?.Length ?? 0))
|
||||||
{
|
{
|
||||||
if (f.Id is null) continue;
|
if (f.Id is null) continue;
|
||||||
var existing = await _db.ProductGroups.FirstOrDefaultAsync(
|
if (options.SkipArchived && f.Archived) continue;
|
||||||
g => g.Name == f.Name && g.Path == (f.PathName ?? f.Name), ct);
|
|
||||||
if (existing is not null)
|
if (existingGroupsByMsId.TryGetValue(f.Id, out var existing))
|
||||||
{
|
{
|
||||||
|
existing.Name = f.Name;
|
||||||
|
existing.Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}";
|
||||||
localGroupByMsId[f.Id] = existing.Id;
|
localGroupByMsId[f.Id] = existing.Id;
|
||||||
|
groupsUpdated++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var g = new ProductGroup
|
|
||||||
|
// Pre-существующая группа без MS id, но с тем же именем в том же
|
||||||
|
// родителе — присваиваем MS id, не плодим дубль.
|
||||||
|
var parentMsId = f.ParentFolder?.Meta?.Href is { } ph && TryExtractId(ph) is { } pid ? pid : null;
|
||||||
|
var parentLocalId = parentMsId is not null && localGroupByMsId.TryGetValue(parentMsId, out var p) ? p : (Guid?)null;
|
||||||
|
if (existingGroupsByParentAndName.TryGetValue((parentLocalId, f.Name.ToLower()), out var sameName))
|
||||||
|
{
|
||||||
|
sameName.MoySkladExternalId = f.Id;
|
||||||
|
sameName.Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}";
|
||||||
|
existingGroupsByMsId[f.Id] = sameName;
|
||||||
|
localGroupByMsId[f.Id] = sameName.Id;
|
||||||
|
groupsUpdated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newGroup = new ProductGroup
|
||||||
{
|
{
|
||||||
OrganizationId = orgId,
|
OrganizationId = orgId,
|
||||||
Name = f.Name,
|
Name = f.Name,
|
||||||
|
ParentId = parentLocalId,
|
||||||
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
|
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
|
||||||
|
MoySkladExternalId = f.Id,
|
||||||
};
|
};
|
||||||
_db.ProductGroups.Add(g);
|
_db.ProductGroups.Add(newGroup);
|
||||||
localGroupByMsId[f.Id] = g.Id;
|
existingGroupsByMsId[f.Id] = newGroup;
|
||||||
|
existingGroupsByParentAndName[(parentLocalId, f.Name.ToLower())] = newGroup;
|
||||||
|
localGroupByMsId[f.Id] = newGroup.Id;
|
||||||
groupsCreated++;
|
groupsCreated++;
|
||||||
}
|
}
|
||||||
if (groupsCreated > 0) await _db.SaveChangesAsync(ct);
|
if (groupsCreated > 0 || groupsUpdated > 0) await _db.SaveChangesAsync(ct);
|
||||||
if (progress is not null) progress.GroupsCreated = groupsCreated;
|
if (progress is not null) progress.GroupsCreated = groupsCreated;
|
||||||
|
|
||||||
// Import products
|
// ------------------------------------------------------------------
|
||||||
|
// Товары — upsert по приоритету: MS id → barcode → article.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
var created = 0;
|
var created = 0;
|
||||||
var updated = 0;
|
var updated = 0;
|
||||||
|
var mergedByBarcode = 0;
|
||||||
var skipped = 0;
|
var skipped = 0;
|
||||||
|
var skippedArchived = 0;
|
||||||
|
var skippedDupeBarcodeInMs = 0;
|
||||||
var total = 0;
|
var total = 0;
|
||||||
// При overwriteExisting=true загружаем товары целиком, чтобы обновлять существующие
|
|
||||||
// вместо создания дубликатов. Ключ = артикул (нормализованный).
|
|
||||||
var existingByArticle = await _db.Products
|
|
||||||
.Where(p => p.Article != null)
|
|
||||||
.ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct);
|
|
||||||
var existingBarcodeSet = new HashSet<string>(
|
|
||||||
await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct));
|
|
||||||
|
|
||||||
await foreach (var p in _client.StreamProductsAsync(token, ct))
|
var existingByMsId = await _db.Products
|
||||||
|
.Where(p => p.OrganizationId == orgId && p.MoySkladExternalId != null)
|
||||||
|
.ToDictionaryAsync(p => p.MoySkladExternalId!, p => p, ct);
|
||||||
|
|
||||||
|
var existingByArticle = await _db.Products
|
||||||
|
.Where(p => p.OrganizationId == orgId && p.Article != null)
|
||||||
|
.ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct);
|
||||||
|
|
||||||
|
// Barcode → Product id (ограничиваем tenant'ом). Нужен для merge-логики.
|
||||||
|
var existingProductIdByBarcode = await _db.ProductBarcodes
|
||||||
|
.Where(b => b.OrganizationId == orgId)
|
||||||
|
.ToDictionaryAsync(b => b.Code, b => b.ProductId, ct);
|
||||||
|
|
||||||
|
var existingBarcodeSet = new HashSet<string>(existingProductIdByBarcode.Keys);
|
||||||
|
|
||||||
|
// Дедуп внутри входа: один и тот же barcode встречается у нескольких
|
||||||
|
// товаров MoySklad → импортируем первый, остальные пропускаем в отчёт.
|
||||||
|
var seenBarcodesInInput = new Dictionary<string, string>(); // barcode → первое имя товара
|
||||||
|
|
||||||
|
await foreach (var p in _client.StreamProductsAsync(token, ct, includeArchived: !options.SkipArchived))
|
||||||
{
|
{
|
||||||
total++;
|
total++;
|
||||||
if (progress is not null) progress.Total = total;
|
if (progress is not null) progress.Total = total;
|
||||||
// Архивных не пропускаем — импортируем как IsActive=false.
|
|
||||||
|
|
||||||
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
|
if (options.SkipArchived && p.Archived)
|
||||||
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingByArticle.ContainsKey(article);
|
|
||||||
|
|
||||||
if (alreadyByArticle && !overwriteExisting)
|
|
||||||
{
|
{
|
||||||
skipped++;
|
skippedArchived++;
|
||||||
if (progress is not null) progress.Skipped = skipped;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Vat товара: из MoySklad.vat если задано, иначе дефолт страны.
|
var barcodes = ExtractBarcodes(p);
|
||||||
// VatEnabled: приоритет p.VatEnabled, fallback — «без НДС» если p.Vat=0.
|
|
||||||
var vat = p.Vat.HasValue ? (decimal)p.Vat.Value : defaultVat;
|
|
||||||
var vatEnabled = p.VatEnabled ?? (p.Vat is null || p.Vat.Value != 0);
|
|
||||||
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;
|
|
||||||
|
|
||||||
|
// 1) Если какой-то из barcodes этого товара уже встречался во
|
||||||
|
// входе под другим именем — пропускаем товар целиком.
|
||||||
|
var dupeInInput = barcodes
|
||||||
|
.Select(b => b.Code)
|
||||||
|
.FirstOrDefault(c => seenBarcodesInInput.ContainsKey(c) && seenBarcodesInInput[c] != p.Name);
|
||||||
|
if (dupeInInput is not null)
|
||||||
|
{
|
||||||
|
skippedDupeBarcodeInMs++;
|
||||||
|
errors.Add($"MS-дубль: товар «{p.Name}» имеет штрихкод {dupeInInput}, уже привязанный к «{seenBarcodesInInput[dupeInInput]}» — пропущен.");
|
||||||
|
if (progress is not null) progress.Errors = errors;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (var b in barcodes) seenBarcodesInInput.TryAdd(b.Code, p.Name);
|
||||||
|
|
||||||
|
// 2) Поиск существующей записи по приоритету.
|
||||||
|
Product? existing = null;
|
||||||
|
var mergeTag = "";
|
||||||
|
if (p.Id is not null && existingByMsId.TryGetValue(p.Id, out var byMsId))
|
||||||
|
{
|
||||||
|
existing = byMsId;
|
||||||
|
}
|
||||||
|
else if (options.MergeByBarcode)
|
||||||
|
{
|
||||||
|
foreach (var b in barcodes)
|
||||||
|
{
|
||||||
|
if (existingProductIdByBarcode.TryGetValue(b.Code, out var pid))
|
||||||
|
{
|
||||||
|
existing = await _db.Products.FirstOrDefaultAsync(x => x.Id == pid, ct);
|
||||||
|
if (existing is not null) { mergeTag = "merge-barcode"; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
|
||||||
|
if (existing is null && !string.IsNullOrWhiteSpace(article) &&
|
||||||
|
existingByArticle.TryGetValue(article, out var byArt))
|
||||||
|
{
|
||||||
|
existing = byArt;
|
||||||
|
mergeTag = "merge-article";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Common fields.
|
||||||
|
var vat = p.Vat.HasValue ? (decimal)p.Vat.Value : defaultVat;
|
||||||
|
// MoySklad useParentVat=true означает, что товар наследует НДС от
|
||||||
|
// группы — у нас такая иерархия не поддерживается, подставляем
|
||||||
|
// дефолт страны (как «без переопределения»).
|
||||||
|
var vatEnabled = p.VatEnabled ?? (p.Vat is null || p.Vat.Value != 0);
|
||||||
|
var groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId
|
||||||
|
&& localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : defaultGroupId;
|
||||||
|
Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null;
|
||||||
|
var uomId = ResolveUom(p, msUomCodeById, localUomsByCode, pieceUomId, kgUomId);
|
||||||
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();
|
||||||
|
|
||||||
Product product;
|
if (existing is not null)
|
||||||
if (alreadyByArticle && overwriteExisting)
|
|
||||||
{
|
{
|
||||||
product = existingByArticle[article!];
|
if (!options.OverwriteExisting && mergeTag == "")
|
||||||
// Обновляем только скалярные поля — коллекции (prices, barcodes) оставляем:
|
{
|
||||||
// там могут быть данные, которые редактировал пользователь после импорта.
|
skipped++;
|
||||||
product.Name = Trim(p.Name, 500);
|
if (progress is not null) progress.Skipped = skipped;
|
||||||
product.Article = Trim(article, 500);
|
continue;
|
||||||
product.Description = p.Description;
|
}
|
||||||
product.Vat = vat;
|
existing.Name = Trim(p.Name, 500) ?? existing.Name;
|
||||||
product.VatEnabled = vatEnabled;
|
existing.Article = Trim(article, 500) ?? existing.Article;
|
||||||
product.ProductGroupId = groupId ?? product.ProductGroupId;
|
existing.Description = p.Description;
|
||||||
product.CountryOfOriginId = countryId ?? product.CountryOfOriginId;
|
existing.Vat = vat;
|
||||||
product.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece;
|
existing.VatEnabled = vatEnabled;
|
||||||
product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
|
existing.ProductGroupId = groupId;
|
||||||
product.ReferencePrice = p.BuyPrice is null ? product.ReferencePrice : p.BuyPrice.Value / 100m;
|
existing.UnitOfMeasureId = uomId;
|
||||||
updated++;
|
existing.CountryOfOriginId = countryId ?? existing.CountryOfOriginId;
|
||||||
if (progress is not null) progress.Updated = updated;
|
existing.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece;
|
||||||
|
existing.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
|
||||||
|
existing.ReferencePrice = p.BuyPrice is null ? existing.ReferencePrice : p.BuyPrice.Value / 100m;
|
||||||
|
if (existing.PurchaseCurrencyId is null) existing.PurchaseCurrencyId = kzt.Id;
|
||||||
|
// Привязываем MS id в любом случае — это ключ будущих upsert'ов.
|
||||||
|
if (string.IsNullOrEmpty(existing.MoySkladExternalId))
|
||||||
|
existing.MoySkladExternalId = p.Id;
|
||||||
|
|
||||||
|
// Добавляем новые штрихкоды, оставляя уже существующие как есть.
|
||||||
|
foreach (var b in barcodes)
|
||||||
|
{
|
||||||
|
if (existingBarcodeSet.Contains(b.Code)) continue;
|
||||||
|
b.OrganizationId = orgId;
|
||||||
|
existing.Barcodes.Add(b);
|
||||||
|
existingBarcodeSet.Add(b.Code);
|
||||||
|
existingProductIdByBarcode[b.Code] = existing.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прайс retail: если у существующего товара нет цены этого типа —
|
||||||
|
// добавляем из MS. Если есть — оставляем (возможно, пользователь
|
||||||
|
// поправил).
|
||||||
|
if (retailPrice is not null)
|
||||||
|
{
|
||||||
|
var alreadyHas = await _db.ProductPrices.AnyAsync(
|
||||||
|
pp => pp.ProductId == existing.Id && pp.PriceTypeId == retailType.Id, ct);
|
||||||
|
if (!alreadyHas)
|
||||||
|
{
|
||||||
|
existing.Prices.Add(new ProductPrice
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
PriceTypeId = retailType.Id,
|
||||||
|
Amount = retailPrice.Value / 100m,
|
||||||
|
CurrencyId = kzt.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mergeTag == "merge-barcode")
|
||||||
|
{
|
||||||
|
mergedByBarcode++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
updated++;
|
||||||
|
if (progress is not null) progress.Updated = updated;
|
||||||
|
}
|
||||||
|
if (p.Id is not null) existingByMsId[p.Id] = existing;
|
||||||
|
if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = existing;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
product = new Product
|
var product = new Product
|
||||||
{
|
{
|
||||||
OrganizationId = orgId,
|
OrganizationId = orgId,
|
||||||
Name = Trim(p.Name, 500),
|
Name = Trim(p.Name, 500) ?? "(без имени)",
|
||||||
Article = Trim(article, 500),
|
Article = Trim(article, 500),
|
||||||
Description = p.Description,
|
Description = p.Description,
|
||||||
UnitOfMeasureId = baseUnit.Id,
|
UnitOfMeasureId = uomId,
|
||||||
Vat = vat,
|
Vat = vat,
|
||||||
VatEnabled = vatEnabled,
|
VatEnabled = vatEnabled,
|
||||||
ProductGroupId = groupId ?? defaultGroupId,
|
ProductGroupId = groupId,
|
||||||
CountryOfOriginId = countryId,
|
CountryOfOriginId = countryId,
|
||||||
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
|
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
|
||||||
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
|
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
|
||||||
ReferencePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
|
ReferencePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
|
||||||
PurchaseCurrencyId = kzt.Id,
|
PurchaseCurrencyId = kzt.Id,
|
||||||
|
MoySkladExternalId = p.Id,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (retailPrice is not null)
|
if (retailPrice is not null)
|
||||||
|
|
@ -293,7 +478,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var b in ExtractBarcodes(p))
|
foreach (var b in barcodes)
|
||||||
{
|
{
|
||||||
if (existingBarcodeSet.Contains(b.Code))
|
if (existingBarcodeSet.Contains(b.Code))
|
||||||
{
|
{
|
||||||
|
|
@ -301,19 +486,21 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||||
if (progress is not null) progress.Errors = errors;
|
if (progress is not null) progress.Errors = errors;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
b.OrganizationId = orgId;
|
||||||
product.Barcodes.Add(b);
|
product.Barcodes.Add(b);
|
||||||
existingBarcodeSet.Add(b.Code);
|
existingBarcodeSet.Add(b.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
_db.Products.Add(product);
|
_db.Products.Add(product);
|
||||||
|
if (p.Id is not null) existingByMsId[p.Id] = product;
|
||||||
if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product;
|
if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product;
|
||||||
|
foreach (var b in product.Barcodes) existingProductIdByBarcode[b.Code] = product.Id;
|
||||||
created++;
|
created++;
|
||||||
if (progress is not null) progress.Created = created;
|
if (progress is not null) progress.Created = created;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush чаще (каждые 100) чтобы при сетевом обрыве на следующей странице
|
// Flush чаще — чтобы при обрыве сохранялось максимум.
|
||||||
// мы сохранили как можно больше и смогли безопасно продолжить с overwrite.
|
if ((created + updated + mergedByBarcode) % 200 == 0) await _db.SaveChangesAsync(ct);
|
||||||
if ((created + updated) % 100 == 0) await _db.SaveChangesAsync(ct);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -325,18 +512,52 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
// Финальная проверка дубликатов штрихкодов (исторические записи или
|
// Финальная проверка дубликатов штрихкодов (на всякий, если
|
||||||
// расхождения c уникальным индексом). Только warning в errors[].
|
// уникальный индекс был нарушен исторически).
|
||||||
var duplicates = await _db.ProductBarcodes
|
var duplicates = await _db.ProductBarcodes
|
||||||
|
.Where(b => b.OrganizationId == orgId)
|
||||||
.GroupBy(b => b.Code)
|
.GroupBy(b => b.Code)
|
||||||
.Where(g => g.Count() > 1)
|
.Where(g => g.Count() > 1)
|
||||||
.Select(g => g.Key)
|
.Select(g => g.Key)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
foreach (var dup in duplicates)
|
foreach (var dup in duplicates)
|
||||||
errors.Add($"Внимание: штрихкод {dup} привязан к нескольким товарам — почисти вручную.");
|
errors.Add($"Внимание: штрихкод {dup} привязан к нескольким товарам в БД.");
|
||||||
if (progress is not null && duplicates.Count > 0) progress.Errors = errors;
|
if (progress is not null && duplicates.Count > 0) progress.Errors = errors;
|
||||||
|
|
||||||
return new MoySkladImportResult(total, created + updated, skipped, groupsCreated, errors);
|
return new MoySkladImportResult(
|
||||||
|
total,
|
||||||
|
created + updated + mergedByBarcode,
|
||||||
|
skipped,
|
||||||
|
groupsCreated,
|
||||||
|
errors)
|
||||||
|
{
|
||||||
|
MergedByBarcode = mergedByBarcode,
|
||||||
|
SkippedArchivedInSource = skippedArchived,
|
||||||
|
SkippedDuplicateBarcodeInSource = skippedDupeBarcodeInMs,
|
||||||
|
GroupsUpdated = groupsUpdated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid ResolveUom(
|
||||||
|
MsProduct p,
|
||||||
|
IReadOnlyDictionary<string, string> msUomCodeById,
|
||||||
|
IReadOnlyDictionary<string, Guid> localUomsByCode,
|
||||||
|
Guid pieceUomId,
|
||||||
|
Guid kgUomId)
|
||||||
|
{
|
||||||
|
// 1) Если weighed=true — всегда kg (у весовых в MS uom может быть что
|
||||||
|
// угодно, но у нас осмысленный default — кг). Нет кг? → штука.
|
||||||
|
if (p.Weighed) return kgUomId != Guid.Empty ? kgUomId : pieceUomId;
|
||||||
|
|
||||||
|
// 2) Иначе по MS uom id → MS code → local code.
|
||||||
|
var msId = p.Uom?.Meta?.Href is { } href ? TryExtractId(href) : null;
|
||||||
|
if (msId is not null && msUomCodeById.TryGetValue(msId, out var code)
|
||||||
|
&& localUomsByCode.TryGetValue(code, out var localId))
|
||||||
|
{
|
||||||
|
return localId;
|
||||||
|
}
|
||||||
|
// 3) Fallback — штука.
|
||||||
|
return pieceUomId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
|
private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
|
||||||
|
|
@ -344,11 +565,15 @@ private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
|
||||||
if (p.Barcodes is null) return [];
|
if (p.Barcodes is null) return [];
|
||||||
var list = new List<ProductBarcode>();
|
var list = new List<ProductBarcode>();
|
||||||
var primarySet = false;
|
var primarySet = false;
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
foreach (var entry in p.Barcodes)
|
foreach (var entry in p.Barcodes)
|
||||||
{
|
{
|
||||||
foreach (var (kind, code) in entry)
|
foreach (var (kind, code) in entry)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(code)) continue;
|
if (string.IsNullOrWhiteSpace(code)) continue;
|
||||||
|
// Дедуп внутри одного товара тоже — иногда MS возвращает один и
|
||||||
|
// тот же код дважды (ean13+code128 на одно значение).
|
||||||
|
if (!seen.Add(code)) continue;
|
||||||
var type = kind switch
|
var type = kind switch
|
||||||
{
|
{
|
||||||
"ean13" => BarcodeType.Ean13,
|
"ean13" => BarcodeType.Ean13,
|
||||||
|
|
@ -359,7 +584,12 @@ private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
|
||||||
"upce" => BarcodeType.Upce,
|
"upce" => BarcodeType.Upce,
|
||||||
_ => BarcodeType.Other,
|
_ => BarcodeType.Other,
|
||||||
};
|
};
|
||||||
list.Add(new ProductBarcode { Code = code.Length > 500 ? code[..500] : code, Type = type, IsPrimary = !primarySet });
|
list.Add(new ProductBarcode
|
||||||
|
{
|
||||||
|
Code = code.Length > 500 ? code[..500] : code,
|
||||||
|
Type = type,
|
||||||
|
IsPrimary = !primarySet,
|
||||||
|
});
|
||||||
primarySet = true;
|
primarySet = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,12 @@ private static void ConfigureProductGroup(EntityTypeBuilder<ProductGroup> b)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.ParentId });
|
b.HasIndex(x => new { x.OrganizationId, x.ParentId });
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.Path });
|
b.HasIndex(x => new { x.OrganizationId, x.Path });
|
||||||
|
// Sprint 32: upsert по внешнему id МойСклада — повторный импорт
|
||||||
|
// находит ту же группу, а не создаёт одноимённую вторую.
|
||||||
|
b.Property(x => x.MoySkladExternalId).HasMaxLength(64);
|
||||||
|
b.HasIndex(x => new { x.OrganizationId, x.MoySkladExternalId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MoySkladExternalId\" IS NOT NULL");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigurePriceType(EntityTypeBuilder<PriceType> b)
|
private static void ConfigurePriceType(EntityTypeBuilder<PriceType> b)
|
||||||
|
|
@ -158,6 +164,13 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
|
||||||
// Sprint 19: partial index по IsArchived — listings default фильтруют
|
// Sprint 19: partial index по IsArchived — listings default фильтруют
|
||||||
// archived=false, селективность хорошая (архивных меньшинство).
|
// archived=false, селективность хорошая (архивных меньшинство).
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.IsArchived });
|
b.HasIndex(x => new { x.OrganizationId, x.IsArchived });
|
||||||
|
// Sprint 32: внешний id МойСклада для идемпотентного upsert'а.
|
||||||
|
// Partial-уникальный индекс: NULL разрешён (товар создан вручную),
|
||||||
|
// но дважды тот же MS id в одной орге — нельзя.
|
||||||
|
b.Property(x => x.MoySkladExternalId).HasMaxLength(64);
|
||||||
|
b.HasIndex(x => new { x.OrganizationId, x.MoySkladExternalId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MoySkladExternalId\" IS NOT NULL");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigureProductPrice(EntityTypeBuilder<ProductPrice> b)
|
private static void ConfigureProductPrice(EntityTypeBuilder<ProductPrice> b)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Sprint 32 — MoySkladExternalId на Product и ProductGroup.
|
||||||
|
///
|
||||||
|
/// Хранит UUID сущности в МойСклад. Нужен, чтобы повторный импорт был
|
||||||
|
/// идемпотентным: находим существующую запись по MS id и обновляем, а
|
||||||
|
/// не создаём одноимённую вторую. Partial-уникальный индекс — NULL
|
||||||
|
/// разрешён (ручные записи), а задан — уникален в рамках org.
|
||||||
|
///
|
||||||
|
/// Применяется идемпотентно через IF NOT EXISTS.</summary>
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20261004210000_Phase32a_MoySkladExternalId")]
|
||||||
|
public partial class Phase32a_MoySkladExternalId : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.Sql(@"
|
||||||
|
ALTER TABLE public.products
|
||||||
|
ADD COLUMN IF NOT EXISTS ""MoySkladExternalId"" varchar(64) NULL;
|
||||||
|
|
||||||
|
ALTER TABLE public.product_groups
|
||||||
|
ADD COLUMN IF NOT EXISTS ""MoySkladExternalId"" varchar(64) NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_products_OrganizationId_MoySkladExternalId""
|
||||||
|
ON public.products (""OrganizationId"", ""MoySkladExternalId"")
|
||||||
|
WHERE ""MoySkladExternalId"" IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_product_groups_OrganizationId_MoySkladExternalId""
|
||||||
|
ON public.product_groups (""OrganizationId"", ""MoySkladExternalId"")
|
||||||
|
WHERE ""MoySkladExternalId"" IS NOT NULL;
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.Sql(@"
|
||||||
|
DROP INDEX IF EXISTS public.""IX_products_OrganizationId_MoySkladExternalId"";
|
||||||
|
DROP INDEX IF EXISTS public.""IX_product_groups_OrganizationId_MoySkladExternalId"";
|
||||||
|
ALTER TABLE public.products DROP COLUMN IF EXISTS ""MoySkladExternalId"";
|
||||||
|
ALTER TABLE public.product_groups DROP COLUMN IF EXISTS ""MoySkladExternalId"";
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
tests/e2e/reports/catalog-import-list.png
Normal file
BIN
tests/e2e/reports/catalog-import-list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
BIN
tests/e2e/reports/catalog-import-product-card.png
Normal file
BIN
tests/e2e/reports/catalog-import-product-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
BIN
tests/e2e/reports/catalog-import-search-barcode.png
Normal file
BIN
tests/e2e/reports/catalog-import-search-barcode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
tests/e2e/reports/catalog-import-search-name.png
Normal file
BIN
tests/e2e/reports/catalog-import-search-name.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
121
tests/e2e/scenarios/stage-ui-catalog-import.spec.ts
Normal file
121
tests/e2e/scenarios/stage-ui-catalog-import.spec.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
/**
|
||||||
|
* Sprint 32 — UI-проверка каталога после полной заливки МойСклада.
|
||||||
|
*
|
||||||
|
* Что проверяем:
|
||||||
|
* 1. На /catalog/products под demo-admin видно ~30k товаров (total в
|
||||||
|
* шапке списка).
|
||||||
|
* 2. Поиск по названию (взяли реально существующий бренд из импорта)
|
||||||
|
* — хотя бы одна строка в выдаче.
|
||||||
|
* 3. Поиск по штрихкоду (конкретный EAN13 из seed'овой выборки) —
|
||||||
|
* ровно одна строка в выдаче.
|
||||||
|
* 4. Клик по этой строке → открывается карточка товара → простой
|
||||||
|
* режим: name + article + группа + ед. изм. + розничная цена
|
||||||
|
* видны; «Типы цен» в sidebar НЕ видно (advanced-only).
|
||||||
|
*
|
||||||
|
* Повторный запуск импорта (0 новых) проверяется отдельно — скриптом
|
||||||
|
* import-twice.sh на стейдже, его результат фиксируется в
|
||||||
|
* docs/catalog-import-progress.md.
|
||||||
|
*/
|
||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
import { request as apiRequest } from '@playwright/test'
|
||||||
|
import { loginToken, watchPage, expectNoErrors } from '../lib/ui.js'
|
||||||
|
|
||||||
|
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
|
||||||
|
const SENTINEL = '__QUICK_LOGIN_PWLESS__'
|
||||||
|
|
||||||
|
// Известные значения из импорта МойСклада — проверяли вручную выборку
|
||||||
|
// 10 товаров, которые есть точно; при пересеиде стенда может потребоваться
|
||||||
|
// обновить (см. docs/catalog-import-progress.md).
|
||||||
|
const KNOWN_BARCODE = '4820039807373' // пошехонский 350гр
|
||||||
|
const KNOWN_NAME_PART = 'шоколад' // ~500+ позиций — устойчивый фильтр
|
||||||
|
|
||||||
|
async function openCatalog(page: Page, token: string) {
|
||||||
|
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' })
|
||||||
|
await page.evaluate((tok) => localStorage.setItem('fm.access_token', tok), token)
|
||||||
|
await page.goto(`${BASE}/catalog/products`, { waitUntil: 'domcontentloaded' })
|
||||||
|
// Ждём, пока таблица отрисовалась (есть хотя бы одна строка или
|
||||||
|
// явный empty-state). 20с — на холодный Suspense-chunk + API.
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 20_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('catalog-import — Demo-магазин после импорта МойСклад', () => {
|
||||||
|
test('открывается список, виден total 30k+, поиск по имени и штрихкоду работает', async ({ browser }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
const ctx = await apiRequest.newContext({ baseURL: BASE, ignoreHTTPSErrors: true, timeout: 60_000 })
|
||||||
|
const token = await loginToken(ctx, 'demo-admin@food-market.local', SENTINEL)
|
||||||
|
await ctx.dispose()
|
||||||
|
|
||||||
|
const browserCtx = await browser.newContext({
|
||||||
|
viewport: { width: 1280, height: 800 },
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
locale: 'ru-RU',
|
||||||
|
})
|
||||||
|
const page = await browserCtx.newPage()
|
||||||
|
const errs = watchPage(page, {
|
||||||
|
// В простом режиме presets/notifications/widgets/какие-то advanced-API
|
||||||
|
// отдают 403 по роль-гардам — ожидаемо, не наш баг.
|
||||||
|
expected4xxContains: [
|
||||||
|
'/api/notifications',
|
||||||
|
'/api/user/presets',
|
||||||
|
'/api/dashboard/widgets',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openCatalog(page, token)
|
||||||
|
|
||||||
|
// Total в шапке списка. ListPageShell рендерит description вида
|
||||||
|
// "30 894 товаров" (ru number formatting с nbsp). Проверяем, что
|
||||||
|
// где-то в области страницы встречается число ≥ 30000.
|
||||||
|
const bodyText = await page.locator('main').innerText()
|
||||||
|
const match = bodyText.match(/(\d[\s \d]*)\s*запис/i)
|
||||||
|
expect(match, 'в шапке списка должно быть "N записей"').not.toBeNull()
|
||||||
|
const total = Number((match![1] || '').replace(/[\s ]/g, ''))
|
||||||
|
expect(total, `${total} записей — ожидали ≥ 30000`).toBeGreaterThanOrEqual(30_000)
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'reports/catalog-import-list.png', fullPage: false })
|
||||||
|
|
||||||
|
// Поиск по частичному названию.
|
||||||
|
const search = page.getByPlaceholder(/поиск|search/i).first()
|
||||||
|
await search.fill(KNOWN_NAME_PART)
|
||||||
|
// Таблица дебаунсит, ждём обновления сети.
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 10_000 })
|
||||||
|
const nameRows = await page.locator('table tbody tr').count()
|
||||||
|
expect(nameRows, `поиск "${KNOWN_NAME_PART}" — ожидали хотя бы одну строку`).toBeGreaterThan(0)
|
||||||
|
await page.screenshot({ path: 'reports/catalog-import-search-name.png', fullPage: false })
|
||||||
|
|
||||||
|
// Поиск по штрихкоду — одна конкретная строка.
|
||||||
|
await search.fill(KNOWN_BARCODE)
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 10_000 })
|
||||||
|
const barRows = page.locator('table tbody tr')
|
||||||
|
await expect(barRows, `штрихкод ${KNOWN_BARCODE} — ожидали хотя бы одну строку`).not.toHaveCount(0)
|
||||||
|
await page.screenshot({ path: 'reports/catalog-import-search-barcode.png', fullPage: false })
|
||||||
|
|
||||||
|
// Открываем первую строку.
|
||||||
|
await barRows.first().click()
|
||||||
|
await page.waitForURL(/\/catalog\/products\/[0-9a-f-]{36}/, { timeout: 10_000 })
|
||||||
|
// Карточка — eager-импорт, ждём секцию «Основное» (появляется всегда).
|
||||||
|
await expect(page.getByText(/^Основное$/).first()).toBeVisible({ timeout: 10_000 })
|
||||||
|
|
||||||
|
// Карточка в простом режиме: label'ы «Название», «Штрихкоды», «Группа»,
|
||||||
|
// «Единица измерения», блок цены. Проверяем по тексту внутри DOM, а не
|
||||||
|
// через main.innerText — т.к. WhatsNewBanner в этом же <main> ломает
|
||||||
|
// поиск общим innerText'ом (срабатывает первым).
|
||||||
|
await expect(page.getByText(/Название/i).first(), 'label «Название»').toBeVisible()
|
||||||
|
await expect(page.getByText(/Штрихкод/i).first(), 'раздел «Штрихкоды»').toBeVisible()
|
||||||
|
await expect(page.getByText(/Группа/i).first(), 'label «Группа»').toBeVisible()
|
||||||
|
await expect(page.getByText(/Единиц/i).first(), 'label «Единица измерения»').toBeVisible()
|
||||||
|
await expect(page.getByText(/Розничная цена|Цена продажи/i).first(), 'блок цены').toBeVisible()
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'reports/catalog-import-product-card.png', fullPage: true })
|
||||||
|
|
||||||
|
// В sidebar НЕ должно быть «Типы цен» (advanced-only).
|
||||||
|
const priceTypesLink = page.locator('aside').locator('visible=true').getByRole('link', { name: /^Типы цен$/i })
|
||||||
|
await expect(priceTypesLink, 'в простом режиме «Типы цен» в sidebar не должно быть').toHaveCount(0)
|
||||||
|
|
||||||
|
expectNoErrors(errs, 'catalog walk')
|
||||||
|
} finally {
|
||||||
|
await browserCtx.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue