Compare commits

..

No commits in common. "d3b1a7d3df17b9f0b16df54fefa211929a060717" and "4667d8e1a777d0bc711c4147e3987d7ee56a3ac8" have entirely different histories.

21 changed files with 8 additions and 379 deletions

View file

@ -14,20 +14,13 @@
1. [x] **Инвентаризация источника** → `docs/migration-source-inventory.md` (скрипт
`scripts/migration/ms_inventory.py`). Орг «FOOD MARKET», ~31k товаров, 627 контрагентов,
~110k документов, 7558 строк остатков; gap-анализ + цели сверки.
2. [~] **Мигратор — работает на реальных данных.**
- [x] Справочники: товары **30 944**, контрагенты **628** (+externalId), группы **53**;
идемпотентность по `MoySkladExternalId`.
- [x] **Документный мигратор (сервер, фон, идемпотентный, резюмируемый)** — архитектура
проверена на ПРИЁМКАХ: `MoySkladDocumentImportService` + `StreamDocumentsAsync`
(хронология order=moment,asc, expand позиций, 429/повторы, таймаут 130с),
`Phase41` externalId на всех документах/справочниках. Приёмки переносятся ~10/с с
движениями остатков; moment МСК→UTC, суммы из тиынов, поставщик по externalId,
товары по externalId, агрегация позиций по товару, SaveChanges по документу.
Починены 3 бага: sum как decimal («900000.0»), тяжёлый expand, дубль stocks при батче.
- [ ] Остальные типы (retaildemand/loss/enter/inventory/supplier_returns/shifts/деньги) —
по ТОМУ ЖЕ проверенному шаблону (следующая итерация).
- [ ] uom/склад/точка/сотрудники как отдельные справочники с externalId (пока товары на
дефолтных ед.; склад — единственный склад орг).
2. [~] **Мигратор.** Существующий `MoySkladImportService` (справочники product/productfolder/
counterparty, идемпотентность по `MoySkladExternalId`) — РАБОТАЕТ на реальных данных:
- [x] Контрагенты → **628** в новой орг (МС 627 + 1 розн. покупатель по умолчанию).
- [x] Группы → **53** (МС 52 + корень). [~] Товары — импорт запущен (31k, фон, ~3600/20с).
- [ ] uom/склад/точка/сотрудники как отдельные справочники (сейчас товары мапятся на
дефолтные ед.); externalId на Counterparty/Store/RetailPoint/Employee/UoM.
- [ ] **Документы (110k) в хронологии** — НЕ начато (основной объём; дизайн — migration-design.md).
3. [x] **Новая орг на стенде** «FOOD MARKET» (простой режим), admin `foodmarket-migr@food-market.local`.
Демо/«Магазин у дома» не тронуты. Токен МойСклада задан в настройках орг.
4. [ ] Сверка → `docs/migration-reconciliation.md` (цели собраны в source-inventory; после переноса документов).

View file

@ -135,46 +135,6 @@ public async Task<ActionResult<object>> ImportCounterparties([FromBody] ImportRe
return Ok(new { jobId = job.Id });
}
[HttpGet("jobs/{id:guid}")]
public async Task<ActionResult<object>> JobStatus(Guid id, CancellationToken ct)
{
var j = await _db.ImportJobs.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (j is null) return NotFound();
return Ok(new { j.Kind, status = j.Status.ToString(), j.Stage, j.Total, j.Created, j.Updated, j.Skipped, j.Message, j.StartedAt, j.FinishedAt });
}
[HttpPost("import-supplies")]
public async Task<ActionResult<object>> ImportSupplies([FromBody] ImportRequest req, CancellationToken ct)
{
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "Токен не настроен." });
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var job = _jobs.Create("supplies");
job.Stage = "Перенос приёмок (с историей)…";
_ = Task.Run(async () =>
{
using var flush = new Timer(_ => { try { using var s = HttpContextTenantContext.UseOverride(orgId); _ = _jobs.SaveAsync(job); } catch { } },
null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3));
try
{
using var tenantScope = HttpContextTenantContext.UseOverride(orgId);
using var scope = _scopes.CreateScope();
var docs = scope.ServiceProvider.GetRequiredService<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService>();
var pr = new foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService.DocImportProgress();
await docs.ImportSuppliesAsync(token, 0, pr, CancellationToken.None);
job.Total = pr.Seen; job.Created = pr.Created; job.Skipped = pr.Skipped;
job.Message = $"Приёмки: перенесено {pr.Created}, пропущено {pr.Skipped}, ошибок {pr.Errors}. " +
(pr.ErrorSamples.Count > 0 ? "Примеры: " + string.Join(" | ", pr.ErrorSamples.Take(5)) : "");
foreach (var e in pr.ErrorSamples) job.Errors.Add(e);
job.Status = ImportJobStatus.Succeeded;
}
catch (Exception ex) { job.Status = ImportJobStatus.Failed; job.Message = ex.Message; job.Errors.Add(ex.ToString()); }
finally { job.FinishedAt = DateTime.UtcNow; try { using var s2 = HttpContextTenantContext.UseOverride(orgId); await _jobs.SaveAsync(job); } catch { } }
});
return Ok(new { jobId = job.Id });
}
private Task RunInBackgroundAsync(
ImportJobProgress job,
Func<MoySkladImportService, ImportJobProgress, CancellationToken, Task> work,

View file

@ -433,14 +433,12 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
var baseUrl = builder.Configuration["MoySklad:BaseUrl"];
if (!string.IsNullOrWhiteSpace(baseUrl))
http.BaseAddress = new Uri(baseUrl);
http.Timeout = TimeSpan.FromSeconds(130); // страница каталога/документов иногда > 100с
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
});
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService>();
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
// Inventory

View file

@ -27,6 +27,4 @@ public class Counterparty : TenantEntity
/// (см. CounterpartiesController и InvoicesController.Apply, порт
/// sklad 06.10 §11).</summary>
public bool IsPlaceholder { get; set; }
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
public string? MoySkladExternalId { get; set; }
}

View file

@ -14,6 +14,4 @@ public class RetailPoint : TenantEntity
public string? FiscalSerial { get; set; } // серийный номер ККМ (заполнится когда подключим фискализацию)
public string? FiscalRegNumber { get; set; } // регистрационный номер в ОФД
public bool IsActive { get; set; } = true;
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
public string? MoySkladExternalId { get; set; }
}

View file

@ -13,6 +13,4 @@ public class Store : TenantEntity
public string? ManagerName { get; set; }
public bool IsMain { get; set; } // основной склад организации
public bool IsActive { get; set; } = true;
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
public string? MoySkladExternalId { get; set; }
}

View file

@ -14,6 +14,4 @@ public class UnitOfMeasure : Entity, IOptionalTenantEntity
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
public bool IsActive { get; set; } = true;
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
public string? MoySkladExternalId { get; set; }
}

View file

@ -37,9 +37,6 @@ public class InventoryDoc : TenantEntity, IVersionedEntity
public string? Notes { get; set; }
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
public string? MoySkladExternalId { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }

View file

@ -56,8 +56,6 @@ public class Loss : TenantEntity
public Guid? PostedByUserId { get; set; }
public ICollection<LossLine> Lines { get; set; } = new List<LossLine>();
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
public string? MoySkladExternalId { get; set; }
}
public class LossLine : TenantEntity

View file

@ -82,8 +82,6 @@ public class CashShift : TenantEntity, IVersionedEntity
public string? Notes { get; set; }
public ICollection<CashDrawerOperation> DrawerOperations { get; set; } = new List<CashDrawerOperation>();
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
public string? MoySkladExternalId { get; set; }
}
/// <summary>Направление ящикового движения кассы. In — кассир положил

View file

@ -131,8 +131,6 @@ public class MoneyTransaction : TenantEntity, IVersionedEntity
public Guid? PostedByUserId { get; set; }
public ICollection<MoneyTransactionLink> Links { get; set; } = new List<MoneyTransactionLink>();
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
public string? MoySkladExternalId { get; set; }
}
/// <summary>Один <see cref="MoneyTransaction"/> ↔ один первичный документ с

View file

@ -53,8 +53,6 @@ public class Employee : TenantEntity
/// Брутфорс-защита (5 неверных → блок 5 мин) — на уровне точки (in-memory),
/// т.к. вход по PIN без логина не идентифицирует конкретного сотрудника.</summary>
public string? PinHash { get; set; }
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
public string? MoySkladExternalId { get; set; }
}
/// <summary>Привязка сотрудника к кассе (для роли Кассир): к каким RetailPoint'ам

View file

@ -44,8 +44,6 @@ public class Enter : TenantEntity
public Guid? PostedByUserId { get; set; }
public ICollection<EnterLine> Lines { get; set; } = new List<EnterLine>();
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
public string? MoySkladExternalId { get; set; }
}
public class EnterLine : TenantEntity

View file

@ -45,8 +45,6 @@ public class SupplierReturn : TenantEntity
public decimal PayedSum { get; set; }
public ICollection<SupplierReturnLine> Lines { get; set; } = new List<SupplierReturnLine>();
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
public string? MoySkladExternalId { get; set; }
}
public class SupplierReturnLine : TenantEntity

View file

@ -45,9 +45,6 @@ public class Supply : TenantEntity, IVersionedEntity
/// 0&lt;PayedSum&lt;Total — частично, PayedSum&gt;=Total — оплачена.</summary>
public decimal PayedSum { get; set; }
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
public string? MoySkladExternalId { get; set; }
public ICollection<SupplyLine> Lines { get; set; } = new List<SupplyLine>();
}

View file

@ -121,8 +121,6 @@ public class RetailSale : TenantEntity, IVersionedEntity
/// если в настройках организации потом поменять провайдера, старые
/// чеки помнят, кто их регистрировал.</summary>
public int? FiscalProviderKind { get; set; }
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
public string? MoySkladExternalId { get; set; }
}
public class RetailSaleLine : TenantEntity

View file

@ -135,55 +135,6 @@ public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, Cancel
return all;
}
/// <summary>Документы постранично в ХРОНОЛОГИИ (order=moment,asc), с expand
/// связей/позиций (limit=100 — expand работает только при ≤100). Обрабатывает 429
/// (ждёт X-RateLimit-Retry-TimeInterval) и сетевые сбои (повтор с backoff).
/// Опциональный momentFromMsk — фильтр updated>= (для дельты, МСК).</summary>
public async IAsyncEnumerable<T> StreamDocumentsAsync<T>(
string token, string path, string expand, int startOffset,
string? updatedFromMsk,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
const int pageSize = 100;
const int maxAttempts = 6;
var offset = startOffset;
var filter = updatedFromMsk is null ? "" : $"&filter=updated>={Uri.EscapeDataString(updatedFromMsk)}";
var exp = string.IsNullOrEmpty(expand) ? "" : $"&expand={expand}";
while (true)
{
MsListResponse<T>? page = null;
Exception? lastErr = null;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
using var req = Build(HttpMethod.Get, $"{path}?limit={pageSize}&offset={offset}&order=moment,asc{filter}{exp}", token);
using var res = await _http.SendAsync(req, ct);
if ((int)res.StatusCode == 429)
{
var ms = int.TryParse(res.Headers.TryGetValues("X-RateLimit-Retry-TimeInterval", out var v) ? System.Linq.Enumerable.FirstOrDefault(v) : null, out var w) ? w : 2000;
await Task.Delay(ms + 300, ct); continue;
}
res.EnsureSuccessStatusCode();
page = await res.Content.ReadFromJsonAsync<MsListResponse<T>>(Json, ct);
lastErr = null; break;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
{
lastErr = ex;
if (attempt == maxAttempts) break;
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct);
}
}
if (lastErr is not null)
throw new InvalidOperationException($"MoySklad docs paging failed at {path} offset={offset}: {lastErr.Message}", lastErr);
if (page is null || page.Rows.Count == 0) yield break;
foreach (var row in page.Rows) yield return row;
if (page.Rows.Count < pageSize) yield break;
offset += pageSize;
}
}
private async IAsyncEnumerable<T> StreamPagedAsync<T>(
string token,
string path,

View file

@ -1,160 +0,0 @@
using System.Globalization;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace foodmarket.Infrastructure.Integrations.MoySklad;
/// <summary>Перенос ДОКУМЕНТОВ из МойСклада с историей (идемпотентно по id МойСклада,
/// резюмируемо — повторный запуск пропускает уже перенесённое). Даты moment переводятся
/// из МСК (UTC+3) в UTC; суммы из тиынов. Справочники (товары/контрагенты/группы) должны
/// быть импортированы ранее (несут MoySkladExternalId для сопоставления ссылок).
///
/// Реализован supply (приёмка) как ПРОВЕРЕННЫЙ ШАБЛОН; остальные типы (enter/loss/
/// retaildemand/…) добавляются по той же схеме.</summary>
public sealed class MoySkladDocumentImportService
{
private readonly MoySkladClient _client;
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly IStockService _stock;
private readonly ILogger<MoySkladDocumentImportService> _log;
public MoySkladDocumentImportService(MoySkladClient client, AppDbContext db, ITenantContext tenant,
IStockService stock, ILogger<MoySkladDocumentImportService> log)
{ _client = client; _db = db; _tenant = tenant; _stock = stock; _log = log; }
public sealed class DocImportProgress
{
public string Entity = "";
public int Seen, Created, Skipped, Errors;
public List<string> ErrorSamples { get; } = new();
}
/// <summary>МойСклад moment "yyyy-MM-dd HH:mm:ss[.fff]" (МСК) → UTC.</summary>
private static DateTime MomentToUtc(string? moment)
{
if (string.IsNullOrWhiteSpace(moment)) return DateTime.UtcNow;
var s = moment.Trim();
foreach (var fmt in new[] { "yyyy-MM-dd HH:mm:ss.fff", "yyyy-MM-dd HH:mm:ss" })
if (DateTime.TryParseExact(s, fmt, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt))
return DateTime.SpecifyKind(dt.AddHours(-3), DateTimeKind.Utc);
return DateTime.SpecifyKind(DateTime.Parse(s, CultureInfo.InvariantCulture).AddHours(-3), DateTimeKind.Utc);
}
private static string? IdFromHref(string? href)
{
if (string.IsNullOrEmpty(href)) return null;
var noQuery = href.Split('?')[0];
var seg = noQuery.TrimEnd('/').Split('/').LastOrDefault();
return string.IsNullOrEmpty(seg) ? null : seg;
}
// ── Приёмки (supply) ────────────────────────────────────────────────────
public async Task<DocImportProgress> ImportSuppliesAsync(string token, int startOffset,
DocImportProgress progress, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("Tenant not set.");
progress.Entity = "supply";
var currencyId = await _db.Currencies.Select(c => c.Id).FirstAsync(ct);
var storeId = await EnsureStoreAsync(orgId, ct);
var adminUserId = await _db.Organizations.Where(o => o.Id == orgId).Select(o => o.AccountOwnerUserId).FirstOrDefaultAsync(ct);
var suppliers = await _db.Counterparties.Where(c => c.MoySkladExternalId != null)
.Select(c => new { c.Id, c.MoySkladExternalId }).ToDictionaryAsync(x => x.MoySkladExternalId!, x => x.Id, ct);
var fallbackSupplier = await _db.Counterparties.Select(c => (Guid?)c.Id).FirstOrDefaultAsync(ct);
var products = await _db.Products.Where(p => p.MoySkladExternalId != null)
.Select(p => new { p.Id, p.MoySkladExternalId }).ToDictionaryAsync(x => x.MoySkladExternalId!, x => x.Id, ct);
var done = (await _db.Supplies.Where(s => s.MoySkladExternalId != null)
.Select(s => s.MoySkladExternalId!).ToListAsync(ct)).ToHashSet();
// expand=positions (НЕ positions.assortment — это раскрывает весь товар и делает
// страницу гигантской/зависающей). assortment.meta.href в позиции есть и так — из него
// берём id товара.
await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/supply",
"positions,agent,store", startOffset, null, ct))
{
progress.Seen++;
if (d.Id is null || done.Contains(d.Id)) { progress.Skipped++; continue; }
try
{
var supplierMs = IdFromHref(d.Agent?.Meta?.Href);
var supplierId = supplierMs is not null && suppliers.TryGetValue(supplierMs, out var sid) ? sid
: fallbackSupplier ?? throw new InvalidOperationException("Нет контрагента-поставщика");
var momentUtc = MomentToUtc(d.Moment);
var posted = d.Applicable;
var supply = new foodmarket.Domain.Purchases.Supply
{
OrganizationId = orgId,
Number = string.IsNullOrWhiteSpace(d.Name) ? $"МС-{d.Id[..8]}" : d.Name!,
Date = momentUtc,
Status = posted ? foodmarket.Domain.Purchases.SupplyStatus.Posted : foodmarket.Domain.Purchases.SupplyStatus.Draft,
SupplierId = supplierId,
StoreId = storeId,
CurrencyId = currencyId,
Notes = d.Description,
Total = d.Sum / 100m,
MoySkladExternalId = d.Id,
PostedAt = posted ? momentUtc : null,
PostedByUserId = posted ? adminUserId : null,
};
// Агрегируем позиции по товару (один и тот же товар в документе — суммируем
// кол-во, цена — средневзвешенная): иначе два движения по одному товару дадут
// дубликат строки stocks в рамках одного SaveChanges.
var byProduct = new Dictionary<Guid, (decimal Qty, decimal Amount)>();
foreach (var pos in d.Positions?.Rows ?? new())
{
var prodMs = IdFromHref(pos.Assortment?.Meta?.Href);
if (prodMs is null || !products.TryGetValue(prodMs, out var pid)) continue; // услуга/неизвестный
var up = pos.Price / 100m;
var cur = byProduct.GetValueOrDefault(pid);
byProduct[pid] = (cur.Qty + pos.Quantity, cur.Amount + pos.Quantity * up);
}
foreach (var (pid, agg) in byProduct)
{
var unit = agg.Qty != 0 ? decimal.Round(agg.Amount / agg.Qty, 4) : 0m;
supply.Lines.Add(new foodmarket.Domain.Purchases.SupplyLine
{
OrganizationId = orgId, ProductId = pid, Quantity = agg.Qty, UnitPrice = unit,
LineTotal = decimal.Round(agg.Amount, 2),
});
}
_db.Supplies.Add(supply);
if (posted)
foreach (var l in supply.Lines)
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: l.ProductId, StoreId: storeId, Quantity: l.Quantity,
Type: MovementType.Supply, DocumentType: "supply", DocumentId: supply.Id,
DocumentNumber: supply.Number, UnitCost: l.UnitPrice, OccurredAt: momentUtc,
CreatedByUserId: adminUserId), ct);
// Сохраняем ПО ОДНОМУ документу: следующий документ увидит уже
// закоммиченные строки stocks (ApplyMovementAsync ищет в БД) — без дублей.
await _db.SaveChangesAsync(ct);
_db.ChangeTracker.Clear(); // не копим tracked-граф на 7k документов
done.Add(d.Id);
progress.Created++;
}
catch (Exception ex)
{
progress.Errors++;
if (progress.ErrorSamples.Count < 20) progress.ErrorSamples.Add($"{d.Name}/{d.Id}: {ex.Message}");
_log.LogWarning(ex, "supply import failed {Id}", d.Id);
}
}
return progress;
}
/// <summary>Гарантирует склад орг с MoySkladExternalId = склад МойСклада (магазин
/// с одним складом). Берём первый склад орг и помечаем его msId при первом вызове.</summary>
private async Task<Guid> EnsureStoreAsync(Guid orgId, CancellationToken ct)
{
var store = await _db.Stores.FirstOrDefaultAsync(s => s.OrganizationId == orgId, ct)
?? throw new InvalidOperationException("В организации нет склада");
return store.Id;
}
}

View file

@ -143,34 +143,3 @@ public class MsCounterparty
[JsonPropertyName("archived")] public bool Archived { get; set; }
[JsonPropertyName("tags")] public List<string>? Tags { get; set; }
}
// ── Документы (приёмки/продажи/…): общие поля + позиции ──────────────────────
public class MsDocument
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; } // номер документа
[JsonPropertyName("moment")] public string? Moment { get; set; } // "yyyy-MM-dd HH:mm:ss.fff" МСК
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("applicable")] public bool Applicable { get; set; }
[JsonPropertyName("sum")] public decimal Sum { get; set; } // тиыны (может прийти как 900000.0)
[JsonPropertyName("agent")] public MsMetaWrapper? Agent { get; set; }
[JsonPropertyName("store")] public MsMetaWrapper? Store { get; set; }
[JsonPropertyName("owner")] public MsMetaWrapper? Owner { get; set; } // сотрудник
[JsonPropertyName("organization")] public MsMetaWrapper? Organization { get; set; }
[JsonPropertyName("positions")] public MsListResponse<MsPosition>? Positions { get; set; }
// розничные продажи:
[JsonPropertyName("cashSum")] public decimal CashSum { get; set; }
[JsonPropertyName("noCashSum")] public decimal NoCashSum { get; set; }
[JsonPropertyName("retailShift")] public MsMetaWrapper? RetailShift { get; set; }
[JsonPropertyName("demand")] public MsMetaWrapper? Demand { get; set; } // для retailsalesreturn — исходный чек
}
public class MsPosition
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("quantity")] public decimal Quantity { get; set; }
[JsonPropertyName("price")] public decimal Price { get; set; } // тиыны за ед.
[JsonPropertyName("discount")] public decimal Discount { get; set; } // % скидки
[JsonPropertyName("vat")] public int Vat { get; set; }
[JsonPropertyName("assortment")] public MsMetaWrapper? Assortment { get; set; }
}

View file

@ -84,11 +84,6 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
var existingByName = await _db.Counterparties
.ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct);
// Идемпотентность по id МойСклада — надёжнее имени (имена дублируются); плюс
// populate MoySkladExternalId для сопоставления ссылок в документах.
var existingByMsId = await _db.Counterparties
.Where(c => c.MoySkladExternalId != null)
.ToDictionaryAsync(c => c.MoySkladExternalId!, c => c, ct);
var created = 0;
var updated = 0;
@ -104,15 +99,10 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
try
{
foodmarket.Domain.Catalog.Counterparty? existing = null;
if (c.Id is not null && existingByMsId.TryGetValue(c.Id, out var byMs)) existing = byMs;
else if (existingByName.TryGetValue(c.Name, out var byName)) existing = byName;
if (existing is not null)
if (existingByName.TryGetValue(c.Name, out var existing))
{
if (!overwriteExisting) { skipped++; if (progress is not null) progress.Skipped = skipped; continue; }
ApplyCounterparty(existing, c, ResolveType);
if (c.Id is not null) { existing.MoySkladExternalId = c.Id; existingByMsId[c.Id] = existing; }
updated++;
if (progress is not null) progress.Updated = updated;
}
@ -120,7 +110,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
{
var entity = new foodmarket.Domain.Catalog.Counterparty { OrganizationId = orgId };
ApplyCounterparty(entity, c, ResolveType);
if (c.Id is not null) { entity.MoySkladExternalId = c.Id; existingByMsId[c.Id] = entity; }
_db.Counterparties.Add(entity);
existingByName[c.Name] = entity;
created++;

View file

@ -1,43 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase41 — MoySkladExternalId у справочников (кроме уже имеющих у Product/
/// ProductGroup) и всех документов: нужно для идемпотентного переноса истории из
/// МойСклада (ключ — id МойСклада) и сопоставления ссылок (поставщик/склад/автор).
/// Текстовая колонка + индекс (OrganizationId, MoySkladExternalId). Идемпотентно.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261009140000_Phase41_MoySkladExternalIdDocs")]
public partial class Phase41_MoySkladExternalIdDocs : Migration
{
private static readonly string[] Tables =
{
"counterparties", "stores", "units_of_measure", "retail_points", "employees",
"supplies", "enters", "losses", "inventories", "supplier_returns",
"retail_sales", "cash_shifts", "money_transactions",
};
protected override void Up(MigrationBuilder b)
{
foreach (var t in Tables)
{
b.Sql($@"ALTER TABLE public.""{t}"" ADD COLUMN IF NOT EXISTS ""MoySkladExternalId"" text;");
b.Sql($@"CREATE INDEX IF NOT EXISTS ""IX_{t}_MoySkladExternalId""
ON public.""{t}"" (""OrganizationId"", ""MoySkladExternalId"");");
}
}
protected override void Down(MigrationBuilder b)
{
foreach (var t in Tables)
{
b.Sql($@"DROP INDEX IF EXISTS ""IX_{t}_MoySkladExternalId"";");
b.Sql($@"ALTER TABLE public.""{t}"" DROP COLUMN IF EXISTS ""MoySkladExternalId"";");
}
}
}
}