feat(migration): документный мигратор МойСклад (приёмки) + externalId на документах
Part 2 (документы) — проверенный шаблон на приёмках: - Phase41: MoySkladExternalId у counterparties/stores/units/retail_points/employees и документов (supplies/enters/losses/inventories/supplier_returns/retail_sales/ cash_shifts/money_transactions) + индексы. Свойства на доменных сущностях. - MoySkladClient.StreamDocumentsAsync: документы в хронологии (order=moment,asc), expand позиций/связей (limit=100), обработка 429/сбоев. - MoySkladDocumentImportService.ImportSuppliesAsync: идемпотентно по msId, резюмируемо (skip перенесённых), moment МСК→UTC, суммы из тиынов, поставщик по externalId контрагента, товары по externalId, проведение → движения остатков (StockService). Журнал ошибок. - Импорт контрагентов теперь пишет MoySkladExternalId (нужно для ссылок в документах). - Эндпоинт POST /api/admin/moysklad/import-supplies (фон, job). Остальные типы — по шаблону. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4667d8e1a7
commit
2ed9f04cb2
|
|
@ -135,6 +135,38 @@ public async Task<ActionResult<object>> ImportCounterparties([FromBody] ImportRe
|
|||
return Ok(new { jobId = job.Id });
|
||||
}
|
||||
|
||||
[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,
|
||||
|
|
|
|||
|
|
@ -439,6 +439,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
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
|
||||
|
|
|
|||
|
|
@ -27,4 +27,6 @@ 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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,4 +14,6 @@ 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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,4 +13,6 @@ 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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,4 +14,6 @@ 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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ 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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ 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
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ 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 — кассир положил
|
||||
|
|
|
|||
|
|
@ -131,6 +131,8 @@ 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"/> ↔ один первичный документ с
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ 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'ам
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ 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
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ 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
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ public class Supply : TenantEntity, IVersionedEntity
|
|||
/// 0<PayedSum<Total — частично, PayedSum>=Total — оплачена.</summary>
|
||||
public decimal PayedSum { get; set; }
|
||||
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
|
||||
public ICollection<SupplyLine> Lines { get; set; } = new List<SupplyLine>();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,8 @@ public class RetailSale : TenantEntity, IVersionedEntity
|
|||
/// если в настройках организации потом поменять провайдера, старые
|
||||
/// чеки помнят, кто их регистрировал.</summary>
|
||||
public int? FiscalProviderKind { get; set; }
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
}
|
||||
|
||||
public class RetailSaleLine : TenantEntity
|
||||
|
|
|
|||
|
|
@ -135,6 +135,55 @@ 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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
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();
|
||||
|
||||
var batch = 0;
|
||||
await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/supply",
|
||||
"positions.assortment,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,
|
||||
};
|
||||
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;
|
||||
supply.Lines.Add(new foodmarket.Domain.Purchases.SupplyLine
|
||||
{
|
||||
OrganizationId = orgId, ProductId = pid, Quantity = pos.Quantity, UnitPrice = up,
|
||||
LineTotal = decimal.Round(pos.Quantity * up, 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);
|
||||
|
||||
done.Add(d.Id);
|
||||
progress.Created++;
|
||||
if (++batch >= 50) { await _db.SaveChangesAsync(ct); batch = 0; }
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (batch > 0) await _db.SaveChangesAsync(ct);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -143,3 +143,34 @@ 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 long Sum { get; set; } // тиыны
|
||||
[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 long CashSum { get; set; }
|
||||
[JsonPropertyName("noCashSum")] public long 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 long Price { get; set; } // тиыны за ед.
|
||||
[JsonPropertyName("discount")] public decimal Discount { get; set; } // % скидки
|
||||
[JsonPropertyName("vat")] public int Vat { get; set; }
|
||||
[JsonPropertyName("assortment")] public MsMetaWrapper? Assortment { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ 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;
|
||||
|
|
@ -99,10 +104,15 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
|||
|
||||
try
|
||||
{
|
||||
if (existingByName.TryGetValue(c.Name, out var existing))
|
||||
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 (!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;
|
||||
}
|
||||
|
|
@ -110,6 +120,7 @@ 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++;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
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"";");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue