Compare commits
2 commits
417030827f
...
2d57ceab54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d57ceab54 | ||
|
|
9506f9edc8 |
|
|
@ -43,7 +43,12 @@
|
|||
дефолтных ед.; склад — единственный склад орг).
|
||||
3. [x] **Новая орг на стенде** «FOOD MARKET» (простой режим), admin `foodmarket-migr@food-market.local`.
|
||||
Демо/«Магазин у дома» не тронуты. Токен МойСклада задан в настройках орг.
|
||||
4. [ ] Сверка → `docs/migration-reconciliation.md` (цели собраны в source-inventory; после переноса документов).
|
||||
4. [~] **Сверка (авто)** → `scripts/migration/reconcile.py` + `docs/migration-reconciliation.md`.
|
||||
Число документов на 09.10 16:00: **возвраты поставщику 550=550 ✓, оприходования 72=72 ✓,
|
||||
списания 3068=3068 ✓**; приёмки 7130 vs 7131 (Δ -1 — одна приёмка не перенеслась, разобрать);
|
||||
розничные продажи — перенос идёт (17 400 из ~97 731). Важно: счётчики МойСклада растут
|
||||
(7125→7131, 97707→97731) — аккаунт ЖИВОЙ (магазин работает) → нужна дельта (Part 5).
|
||||
Остатки/долги/выручка-по-месяцам — секции после завершения продаж.
|
||||
5. [ ] Дельта-досинхронизация (updated>= МСК + удалённые).
|
||||
6. [ ] Производительность/надёжность (резюмируемый курсор документов).
|
||||
7. [ ] Runbook «день перехода» + откат.
|
||||
|
|
|
|||
15
docs/migration-reconciliation.md
Normal file
15
docs/migration-reconciliation.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Сверка переноса МойСклад → ALL MARKET
|
||||
|
||||
_Сгенерировано 2026-10-09 16:00:49._
|
||||
|
||||
## 1. Число документов по типам
|
||||
|
||||
| Тип | МойСклад | ALL MARKET | Δ | Статус |
|
||||
|---|---|---|---|---|
|
||||
| Приёмки | 7131 | 7130 | -1 | ~ идёт перенос |
|
||||
| Возвраты поставщику | 550 | 550 | 0 | ✓ |
|
||||
| Оприходования | 72 | 72 | 0 | ✓ |
|
||||
| Списания | 3068 | 3068 | 0 | ✓ |
|
||||
| Розничные продажи | 97731 | 17400 | -80331 | ~ идёт перенос |
|
||||
|
||||
_Доккаунты: есть расхождения/перенос продолжается (см. Δ<0 — ещё не все)._
|
||||
54
scripts/migration/reconcile.py
Normal file
54
scripts/migration/reconcile.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Сверка переноса МойСклад → ALL MARKET (только чтение обоих). Сравнивает:
|
||||
- число документов каждого типа; - остатки товар×склад (report/stock/all); - выручку и
|
||||
число чеков по месяцам. Пишет docs/migration-reconciliation.md. Расхождения помечает."""
|
||||
import os, json, gzip, time, urllib.request, urllib.error, sys, ssl
|
||||
from collections import defaultdict
|
||||
MS="https://api.moysklad.ru/api/remap/1.2"
|
||||
AM="https://test.admin.food-market.kz"
|
||||
MSTOK=[l.split("=",1)[1].strip() for l in open(os.path.expanduser("~/.food-market-secrets/moysklad.env")) if l.startswith("MOYSKLAD_TOKEN=")][0]
|
||||
_ctx=ssl.create_default_context(); _ctx.check_hostname=False; _ctx.verify_mode=ssl.CERT_NONE
|
||||
|
||||
def _get(url,hdr):
|
||||
for i in range(4):
|
||||
try:
|
||||
req=urllib.request.Request(url,headers=hdr)
|
||||
with urllib.request.urlopen(req,timeout=130,context=_ctx) as r:
|
||||
raw=r.read()
|
||||
if r.headers.get("Content-Encoding")=="gzip": raw=gzip.decompress(raw)
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code==429: time.sleep(float(e.headers.get("X-RateLimit-Retry-TimeInterval",2000))/1000+0.5); continue
|
||||
if e.code>=500: time.sleep(2*(i+1)); continue
|
||||
raise
|
||||
except Exception: time.sleep(2*(i+1))
|
||||
return None
|
||||
def ms(path): return _get(MS+path,{"Authorization":f"Bearer {MSTOK}","Accept-Encoding":"gzip"})
|
||||
def am_token():
|
||||
import urllib.parse
|
||||
data=urllib.parse.urlencode({"grant_type":"password","username":"foodmarket-migr@food-market.local","password":"Migrate12345!","client_id":"food-market-web","scope":"openid profile email roles api"}).encode()
|
||||
req=urllib.request.Request(AM+"/connect/token",data=data,headers={"Content-Type":"application/x-www-form-urlencoded"})
|
||||
with urllib.request.urlopen(req,timeout=60,context=_ctx) as r: return json.loads(r.read())["access_token"]
|
||||
TOK=am_token()
|
||||
def am(path): return _get(AM+path,{"Authorization":f"Bearer {TOK}","Accept-Encoding":"gzip"})
|
||||
def ms_size(ent): d=ms(f"/entity/{ent}?limit=1"); return d.get("meta",{}).get("size") if d else None
|
||||
def am_total(path): d=am(path+("&" if "?" in path else "?")+"take=1&pageSize=1"); return d.get("total") if d else None
|
||||
|
||||
print("# Сверка переноса МойСклад → ALL MARKET\n")
|
||||
print(f"_Сгенерировано {time.strftime('%Y-%m-%d %H:%M:%S')}._\n")
|
||||
print("## 1. Число документов по типам\n")
|
||||
print("| Тип | МойСклад | ALL MARKET | Δ | Статус |")
|
||||
print("|---|---|---|---|---|")
|
||||
DOCS=[("Приёмки","supply","/api/purchases/supplies"),("Возвраты поставщику","purchasereturn","/api/purchases/supplier-returns"),
|
||||
("Оприходования","enter","/api/inventory/enters"),("Списания","loss","/api/inventory/losses"),
|
||||
("Розничные продажи","retaildemand","/api/sales/retail")]
|
||||
allzero=True
|
||||
for label,ent,ap in DOCS:
|
||||
a=ms_size(ent); b=am_total(ap);
|
||||
try: d=(b or 0)-(a or 0)
|
||||
except: d="?"
|
||||
ok="✓" if d==0 else ("~ идёт перенос" if isinstance(d,int) and d<0 else "✗")
|
||||
if d!=0: allzero=False
|
||||
print(f"| {label} | {a} | {b} | {d} | {ok} |")
|
||||
time.sleep(0.15)
|
||||
print(f"\n_Доккаунты: {'все сходятся' if allzero else 'есть расхождения/перенос продолжается (см. Δ<0 — ещё не все)'}._")
|
||||
|
|
@ -151,6 +151,7 @@ public async Task<ActionResult<object>> ImportSupplies([FromBody] ImportRequest
|
|||
return BadRequest(new { error = "Токен не настроен." });
|
||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||
var type = HttpContext.Request.Query["type"].ToString(); // захватываем ДО Task.Run (HttpContext потом недоступен)
|
||||
int startOffset = int.TryParse(HttpContext.Request.Query["offset"].ToString(), out var off) ? off : 0;
|
||||
var job = _jobs.Create(string.IsNullOrEmpty(type) ? "supplies" : type);
|
||||
job.Stage = $"Перенос документов ({(string.IsNullOrEmpty(type) ? "приёмки" : type)}) с историей…";
|
||||
_ = Task.Run(async () =>
|
||||
|
|
@ -165,11 +166,11 @@ public async Task<ActionResult<object>> ImportSupplies([FromBody] ImportRequest
|
|||
var pr = new foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService.DocImportProgress();
|
||||
switch (type)
|
||||
{
|
||||
case "enter": await docs.ImportEntersAsync(token, pr, CancellationToken.None); break;
|
||||
case "loss": await docs.ImportLossesAsync(token, pr, CancellationToken.None); break;
|
||||
case "purchasereturn": await docs.ImportSupplierReturnsAsync(token, pr, CancellationToken.None); break;
|
||||
case "retaildemand": await docs.ImportRetailSalesAsync(token, pr, CancellationToken.None); break;
|
||||
default: await docs.ImportSuppliesAsync(token, 0, pr, CancellationToken.None); break;
|
||||
case "enter": await docs.ImportEntersAsync(token, startOffset, pr, CancellationToken.None); break;
|
||||
case "loss": await docs.ImportLossesAsync(token, startOffset, pr, CancellationToken.None); break;
|
||||
case "purchasereturn": await docs.ImportSupplierReturnsAsync(token, startOffset, pr, CancellationToken.None); break;
|
||||
case "retaildemand": await docs.ImportRetailSalesAsync(token, startOffset, pr, CancellationToken.None); break;
|
||||
default: await docs.ImportSuppliesAsync(token, startOffset, pr, CancellationToken.None); break;
|
||||
}
|
||||
job.Total = pr.Seen; job.Created = pr.Created; job.Skipped = pr.Skipped;
|
||||
job.Message = $"Приёмки: перенесено {pr.Created}, пропущено {pr.Skipped}, ошибок {pr.Errors}. " +
|
||||
|
|
|
|||
|
|
@ -195,14 +195,14 @@ private async Task<DocMaps> LoadMapsAsync(Guid orgId, CancellationToken ct)
|
|||
}
|
||||
|
||||
// ── Оприходования (enter) ────────────────────────────────────────────────
|
||||
public async Task<DocImportProgress> ImportEntersAsync(string token, DocImportProgress progress, CancellationToken ct)
|
||||
public async Task<DocImportProgress> ImportEntersAsync(string token, int startOffset, DocImportProgress progress, CancellationToken ct)
|
||||
{
|
||||
var orgId = _tenant.OrganizationId!.Value; progress.Entity = "enter";
|
||||
var m = await LoadMapsAsync(orgId, ct);
|
||||
var done = (await _db.Enters.Where(x => x.MoySkladExternalId != null).Select(x => x.MoySkladExternalId!).ToListAsync(ct)).ToHashSet();
|
||||
var used = (await _db.Enters.Select(x => x.Number).ToListAsync(ct)).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
string Uniq(string b, int y) { if (used.Add(b)) return b; var w = $"{b}-{y}"; if (used.Add(w)) return w; for (var n = 2; ; n++) { var c = $"{b}-{y}-{n}"; if (used.Add(c)) return c; } }
|
||||
await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/enter", "positions,store", 0, null, ct))
|
||||
await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/enter", "positions,store", startOffset, null, ct))
|
||||
{
|
||||
progress.Seen++; if (d.Id is null || done.Contains(d.Id)) { progress.Skipped++; continue; }
|
||||
try
|
||||
|
|
@ -224,14 +224,14 @@ await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/
|
|||
}
|
||||
|
||||
// ── Списания (loss) ──────────────────────────────────────────────────────
|
||||
public async Task<DocImportProgress> ImportLossesAsync(string token, DocImportProgress progress, CancellationToken ct)
|
||||
public async Task<DocImportProgress> ImportLossesAsync(string token, int startOffset, DocImportProgress progress, CancellationToken ct)
|
||||
{
|
||||
var orgId = _tenant.OrganizationId!.Value; progress.Entity = "loss";
|
||||
var m = await LoadMapsAsync(orgId, ct);
|
||||
var done = (await _db.Losses.Where(x => x.MoySkladExternalId != null).Select(x => x.MoySkladExternalId!).ToListAsync(ct)).ToHashSet();
|
||||
var used = (await _db.Losses.Select(x => x.Number).ToListAsync(ct)).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
string Uniq(string b, int y) { if (used.Add(b)) return b; var w = $"{b}-{y}"; if (used.Add(w)) return w; for (var n = 2; ; n++) { var c = $"{b}-{y}-{n}"; if (used.Add(c)) return c; } }
|
||||
await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/loss", "positions,store", 0, null, ct))
|
||||
await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/loss", "positions,store", startOffset, null, ct))
|
||||
{
|
||||
progress.Seen++; if (d.Id is null || done.Contains(d.Id)) { progress.Skipped++; continue; }
|
||||
try
|
||||
|
|
@ -253,14 +253,14 @@ await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/
|
|||
}
|
||||
|
||||
// ── Возвраты поставщику (purchasereturn) ─────────────────────────────────
|
||||
public async Task<DocImportProgress> ImportSupplierReturnsAsync(string token, DocImportProgress progress, CancellationToken ct)
|
||||
public async Task<DocImportProgress> ImportSupplierReturnsAsync(string token, int startOffset, DocImportProgress progress, CancellationToken ct)
|
||||
{
|
||||
var orgId = _tenant.OrganizationId!.Value; progress.Entity = "purchasereturn";
|
||||
var m = await LoadMapsAsync(orgId, ct);
|
||||
var done = (await _db.SupplierReturns.Where(x => x.MoySkladExternalId != null).Select(x => x.MoySkladExternalId!).ToListAsync(ct)).ToHashSet();
|
||||
var used = (await _db.SupplierReturns.Select(x => x.Number).ToListAsync(ct)).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
string Uniq(string b, int y) { if (used.Add(b)) return b; var w = $"{b}-{y}"; if (used.Add(w)) return w; for (var n = 2; ; n++) { var c = $"{b}-{y}-{n}"; if (used.Add(c)) return c; } }
|
||||
await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/purchasereturn", "positions,agent,store", 0, null, ct))
|
||||
await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/purchasereturn", "positions,agent,store", startOffset, null, ct))
|
||||
{
|
||||
progress.Seen++; if (d.Id is null || done.Contains(d.Id)) { progress.Skipped++; continue; }
|
||||
try
|
||||
|
|
@ -284,14 +284,14 @@ await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/
|
|||
}
|
||||
|
||||
// ── Розничные продажи (retaildemand) ─────────────────────────────────────
|
||||
public async Task<DocImportProgress> ImportRetailSalesAsync(string token, DocImportProgress progress, CancellationToken ct)
|
||||
public async Task<DocImportProgress> ImportRetailSalesAsync(string token, int startOffset, DocImportProgress progress, CancellationToken ct)
|
||||
{
|
||||
var orgId = _tenant.OrganizationId!.Value; progress.Entity = "retaildemand";
|
||||
var m = await LoadMapsAsync(orgId, ct);
|
||||
var done = (await _db.RetailSales.Where(x => x.MoySkladExternalId != null).Select(x => x.MoySkladExternalId!).ToListAsync(ct)).ToHashSet();
|
||||
var used = (await _db.RetailSales.Select(x => x.Number).ToListAsync(ct)).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
string Uniq(string b, int y) { if (used.Add(b)) return b; var w = $"{b}-{y}"; if (used.Add(w)) return w; for (var n = 2; ; n++) { var c = $"{b}-{y}-{n}"; if (used.Add(c)) return c; } }
|
||||
await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/retaildemand", "positions,store", 0, null, ct))
|
||||
await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/retaildemand", "positions,store", startOffset, null, ct))
|
||||
{
|
||||
progress.Seen++; if (d.Id is null || done.Contains(d.Id)) { progress.Skipped++; continue; }
|
||||
try
|
||||
|
|
|
|||
Loading…
Reference in a new issue