feat(superadmin): переключатель подгрузки из МойСклад + состояние прогона
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Суперадмин управляет дельта-подгрузкой МойСклад на стенд флагом в «Настройках платформы» (по умолчанию ВЫКЛ), не трогая systemd-таймер руками. - PlatformSettings: MoySkladDeltaSyncEnabled (bool, default false) + состояние последнего прогона (startedAt/finishedAt/ok/migrated/error); миграция Phase46 (ADD COLUMN IF NOT EXISTS, идемпотентно). - Super-admin API: GET/PUT /platform-settings/moysklad-delta — тоггл с reason, пишется в SuperAdminAuditLog; GET отдаёт состояние прогона. Только SuperAdmin. - Admin API (для скрипта, роль Admin): GET /admin/moysklad/delta-sync (флаг), POST /admin/moysklad/delta-sync/report (итог прогона → PlatformSettings). - Серверная защита: import-supplies при delta=true и выключенном флаге → 409 с понятной причиной. Ручной импорт из мастера (без delta=true) не затронут. - delta_sync.py: сначала спрашивает флаг — при «Выкл» одна строка в лог и выход без изменения lastSync (повторное включение догонит пропущенное); помечает запросы delta=true; в конце репортит итог прогона суперадмину. - SuperAdminPlatformSettingsPage: блок «Подгрузка из МойСклад» — тоггл Вкл/Выкл с причиной, подпись, состояние последнего прогона. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e4d756e004
commit
42e4f630b1
|
|
@ -69,12 +69,50 @@ def am_token():
|
||||||
return json.loads(r.read())["access_token"]
|
return json.loads(r.read())["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
class DeltaDisabled(Exception):
|
||||||
|
"""Флаг подгрузки выключен суперадмином (эндпоинт ответил 409)."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_delta_status(tok):
|
||||||
|
"""Спрашивает у API, включена ли подгрузка. Источник правды — PlatformSettings,
|
||||||
|
которым управляет суперадмин. При «Выкл» скрипт выходит, не трогая lastSync."""
|
||||||
|
req = urllib.request.Request(AM + "/api/admin/moysklad/delta-sync",
|
||||||
|
headers={"Authorization": f"Bearer {tok}"})
|
||||||
|
with urllib.request.urlopen(req, timeout=60, context=_ctx) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
|
||||||
|
def report_run(tok, started_utc, ok, migrated, error):
|
||||||
|
"""Пишет итог прогона в PlatformSettings, чтобы суперадмин видел его на
|
||||||
|
странице «Настройки платформы». Ошибку самого отчёта глотаем — она не должна
|
||||||
|
ронять прогон."""
|
||||||
|
try:
|
||||||
|
body = json.dumps({
|
||||||
|
"startedAt": started_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"finishedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"ok": bool(ok), "migrated": int(migrated),
|
||||||
|
"error": (error[:2000] if error else None),
|
||||||
|
}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(AM + "/api/admin/moysklad/delta-sync/report", data=body,
|
||||||
|
headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"}, method="POST")
|
||||||
|
urllib.request.urlopen(req, timeout=60, context=_ctx).read()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [report] не удалось записать итог прогона: {e}")
|
||||||
|
|
||||||
|
|
||||||
def trigger(tok, doc_type, updated_from):
|
def trigger(tok, doc_type, updated_from):
|
||||||
q = urllib.parse.urlencode({"type": doc_type, "updatedFrom": updated_from})
|
# delta=true помечает запрос как дельта-подгрузку: при выключенном флаге
|
||||||
|
# сервер ответит 409 (защита даже при ручном запуске скрипта).
|
||||||
|
q = urllib.parse.urlencode({"type": doc_type, "updatedFrom": updated_from, "delta": "true"})
|
||||||
req = urllib.request.Request(AM + "/api/admin/moysklad/import-supplies?" + q, data=b"{}",
|
req = urllib.request.Request(AM + "/api/admin/moysklad/import-supplies?" + q, data=b"{}",
|
||||||
headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"}, method="POST")
|
headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"}, method="POST")
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60, context=_ctx) as r:
|
with urllib.request.urlopen(req, timeout=60, context=_ctx) as r:
|
||||||
return json.loads(r.read())["jobId"]
|
return json.loads(r.read())["jobId"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 409:
|
||||||
|
raise DeltaDisabled()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def poll(tok, job_id, timeout_s=1800):
|
def poll(tok, job_id, timeout_s=1800):
|
||||||
|
|
@ -108,6 +146,20 @@ def main():
|
||||||
if "--since" in sys.argv:
|
if "--since" in sys.argv:
|
||||||
since = sys.argv[sys.argv.index("--since") + 1]
|
since = sys.argv[sys.argv.index("--since") + 1]
|
||||||
st = load_state()
|
st = load_state()
|
||||||
|
tok = am_token()
|
||||||
|
|
||||||
|
# Главный флаг: источник правды — PlatformSettings (суперадмин). При «Выкл» —
|
||||||
|
# одна строка в логе и выход; lastSync НЕ трогаем, чтобы при повторном включении
|
||||||
|
# первый прогон догнал пропущенное.
|
||||||
|
try:
|
||||||
|
status = get_delta_status(tok)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Не удалось получить флаг подгрузки у API: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
if not status.get("enabled", False):
|
||||||
|
print("Подгрузка из МойСклад выключена (флаг Выкл) — выходим, lastSync не трогаем.")
|
||||||
|
return
|
||||||
|
|
||||||
last = since or st.get("lastSync")
|
last = since or st.get("lastSync")
|
||||||
if not last:
|
if not last:
|
||||||
print("Нет lastSync. Укажи --since 'YYYY-MM-DD HH:MM:SS' (МСК) при первом запуске.")
|
print("Нет lastSync. Укажи --since 'YYYY-MM-DD HH:MM:SS' (МСК) при первом запуске.")
|
||||||
|
|
@ -118,11 +170,14 @@ def main():
|
||||||
last = last_dt.strftime("%Y-%m-%d %H:%M:%S")
|
last = last_dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
started_utc = datetime.now(timezone.utc)
|
||||||
run_start = datetime.now(MSK).strftime("%Y-%m-%d %H:%M:%S")
|
run_start = datetime.now(MSK).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
print(f"=== Дельта-синхронизация: изменения с {last} (МСК), старт {run_start} ===")
|
print(f"=== Дельта-синхронизация: изменения с {last} (МСК), старт {run_start} ===")
|
||||||
tok = am_token()
|
|
||||||
|
|
||||||
total_created = 0
|
total_created = 0
|
||||||
|
ok = False
|
||||||
|
error_text = None
|
||||||
|
try:
|
||||||
for dt in DOC_TYPES:
|
for dt in DOC_TYPES:
|
||||||
jid = trigger(tok, dt, last)
|
jid = trigger(tok, dt, last)
|
||||||
res = poll(tok, jid)
|
res = poll(tok, jid)
|
||||||
|
|
@ -154,7 +209,19 @@ def main():
|
||||||
st["lastCreated"] = total_created
|
st["lastCreated"] = total_created
|
||||||
st["lastDeletions"] = deleted
|
st["lastDeletions"] = deleted
|
||||||
save_state(st)
|
save_state(st)
|
||||||
|
ok = True
|
||||||
print(f"=== Готово. Перенесено изменений: {total_created}. lastSync → {run_start}. Состояние: {STATE} ===")
|
print(f"=== Готово. Перенесено изменений: {total_created}. lastSync → {run_start}. Состояние: {STATE} ===")
|
||||||
|
except DeltaDisabled:
|
||||||
|
# Флаг выключили посреди прогона — выходим чисто, lastSync не трогаем.
|
||||||
|
print("Подгрузка выключена во время прогона (409) — выходим, lastSync не трогаем.")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
error_text = str(e)
|
||||||
|
print(f"ОШИБКА прогона: {e}")
|
||||||
|
finally:
|
||||||
|
report_run(tok, started_utc, ok, total_created, error_text)
|
||||||
|
if not ok:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using foodmarket.Api.Infrastructure.Tenancy;
|
using foodmarket.Api.Infrastructure.Tenancy;
|
||||||
using foodmarket.Application.Common.Tenancy;
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Domain.Platform;
|
||||||
using foodmarket.Infrastructure.Integrations.MoySklad;
|
using foodmarket.Infrastructure.Integrations.MoySklad;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
@ -143,9 +144,64 @@ public async Task<ActionResult<object>> JobStatus(Guid id, CancellationToken ct)
|
||||||
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 });
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Дельта-подгрузка из МойСклад: флаг и отчёт о прогоне (для delta_sync.py) ──
|
||||||
|
// Читается/пишется миграционным аккаунтом (роль Admin). SuperAdmin управляет
|
||||||
|
// флагом на /super-admin/platform-settings/moysklad-delta; здесь — только чтение
|
||||||
|
// флага скриптом и запись итога последнего прогона, чтобы SuperAdmin его видел.
|
||||||
|
|
||||||
|
public record DeltaStatusDto(
|
||||||
|
bool Enabled,
|
||||||
|
DateTime? LastRunStartedAt, DateTime? LastRunFinishedAt,
|
||||||
|
bool? LastRunOk, int? LastRunMigrated, string? LastRunError);
|
||||||
|
|
||||||
|
public record DeltaReportInput(
|
||||||
|
DateTime? StartedAt, DateTime? FinishedAt, bool Ok, int Migrated, string? Error);
|
||||||
|
|
||||||
|
[HttpGet("delta-sync")]
|
||||||
|
public async Task<ActionResult<DeltaStatusDto>> GetDeltaSync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var s = await _db.PlatformSettings.AsNoTracking().FirstOrDefaultAsync(ct);
|
||||||
|
return new DeltaStatusDto(
|
||||||
|
s?.MoySkladDeltaSyncEnabled ?? false,
|
||||||
|
s?.MoySkladDeltaLastRunStartedAt, s?.MoySkladDeltaLastRunFinishedAt,
|
||||||
|
s?.MoySkladDeltaLastRunOk, s?.MoySkladDeltaLastRunMigrated, s?.MoySkladDeltaLastRunError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("delta-sync/report")]
|
||||||
|
public async Task<IActionResult> ReportDeltaSync([FromBody] DeltaReportInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var s = await _db.PlatformSettings.FirstOrDefaultAsync(ct);
|
||||||
|
if (s is null) { s = new PlatformSettings(); _db.PlatformSettings.Add(s); }
|
||||||
|
s.MoySkladDeltaLastRunStartedAt = input.StartedAt?.ToUniversalTime();
|
||||||
|
s.MoySkladDeltaLastRunFinishedAt = (input.FinishedAt ?? DateTime.UtcNow).ToUniversalTime();
|
||||||
|
s.MoySkladDeltaLastRunOk = input.Ok;
|
||||||
|
s.MoySkladDeltaLastRunMigrated = input.Migrated;
|
||||||
|
s.MoySkladDeltaLastRunError = string.IsNullOrWhiteSpace(input.Error)
|
||||||
|
? null
|
||||||
|
: (input.Error.Length > 2000 ? input.Error[..2000] : input.Error);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("import-supplies")]
|
[HttpPost("import-supplies")]
|
||||||
public async Task<ActionResult<object>> ImportSupplies([FromBody] ImportRequest req, CancellationToken ct)
|
public async Task<ActionResult<object>> ImportSupplies([FromBody] ImportRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Серверная защита дельта-подгрузки: запросы от delta_sync.py помечены
|
||||||
|
// delta=true. При выключенном флаге — отказ с понятной причиной, чтобы
|
||||||
|
// подгрузка не шла даже при ручном запуске скрипта. Ручной импорт из
|
||||||
|
// мастера подключения delta=true не передаёт — он не затрагивается.
|
||||||
|
if (HttpContext.Request.Query["delta"].ToString() == "true")
|
||||||
|
{
|
||||||
|
var enabled = await _db.PlatformSettings.AsNoTracking()
|
||||||
|
.Select(p => (bool?)p.MoySkladDeltaSyncEnabled).FirstOrDefaultAsync(ct) ?? false;
|
||||||
|
if (!enabled)
|
||||||
|
return StatusCode(StatusCodes.Status409Conflict, new
|
||||||
|
{
|
||||||
|
error = "Подгрузка из МойСклад выключена суперадмином. Включите её в «Настройки платформы».",
|
||||||
|
deltaDisabled = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
|
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
return BadRequest(new { error = "Токен не настроен." });
|
return BadRequest(new { error = "Токен не настроен." });
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,13 @@ public record PlatformSettingsInput(
|
||||||
|
|
||||||
public record TestSendInput(string ToEmail, string Subject, string Body);
|
public record TestSendInput(string ToEmail, string Subject, string Body);
|
||||||
|
|
||||||
|
public record MoySkladDeltaDto(
|
||||||
|
bool Enabled,
|
||||||
|
DateTime? LastRunStartedAt, DateTime? LastRunFinishedAt,
|
||||||
|
bool? LastRunOk, int? LastRunMigrated, string? LastRunError);
|
||||||
|
|
||||||
|
public record MoySkladDeltaInput(bool Enabled, string Reason);
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PlatformSettingsDto>> Get(CancellationToken ct)
|
public async Task<ActionResult<PlatformSettingsDto>> Get(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
@ -132,6 +139,41 @@ public async Task<IActionResult> TestSend([FromBody] TestSendInput input, Cancel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Подгрузка из МойСклад (дельта-синхронизация) ─────────────────────────
|
||||||
|
|
||||||
|
[HttpGet("moysklad-delta")]
|
||||||
|
public async Task<ActionResult<MoySkladDeltaDto>> GetMoySkladDelta(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var s = await GetOrCreateAsync(ct);
|
||||||
|
return new MoySkladDeltaDto(
|
||||||
|
s.MoySkladDeltaSyncEnabled,
|
||||||
|
s.MoySkladDeltaLastRunStartedAt, s.MoySkladDeltaLastRunFinishedAt,
|
||||||
|
s.MoySkladDeltaLastRunOk, s.MoySkladDeltaLastRunMigrated, s.MoySkladDeltaLastRunError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("moysklad-delta")]
|
||||||
|
public async Task<IActionResult> SetMoySkladDelta([FromBody] MoySkladDeltaInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input.Reason) || input.Reason.Trim().Length < 10)
|
||||||
|
return BadRequest(new { error = "Причина изменения обязательна (≥ 10 символов) — она пишется в журнал." });
|
||||||
|
|
||||||
|
var s = await GetOrCreateAsync(ct);
|
||||||
|
var prev = s.MoySkladDeltaSyncEnabled;
|
||||||
|
if (prev == input.Enabled)
|
||||||
|
return NoContent(); // идемпотентно, не плодим аудит-записи на no-op
|
||||||
|
|
||||||
|
s.MoySkladDeltaSyncEnabled = input.Enabled;
|
||||||
|
s.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await LogAsync("MoySkladDeltaSyncToggle",
|
||||||
|
$"Подгрузка из МойСклад: {(input.Enabled ? "ВКЛ" : "ВЫКЛ")}",
|
||||||
|
input.Reason,
|
||||||
|
System.Text.Json.JsonSerializer.Serialize(new { prev, next = input.Enabled }), ct);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<PlatformSettings> GetOrCreateAsync(CancellationToken ct)
|
private async Task<PlatformSettings> GetOrCreateAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var s = await _db.PlatformSettings.FirstOrDefaultAsync(ct);
|
var s = await _db.PlatformSettings.FirstOrDefaultAsync(ct);
|
||||||
|
|
|
||||||
|
|
@ -42,4 +42,25 @@ public class PlatformSettings : Entity
|
||||||
public string? WebPushVapidPrivateKey { get; set; }
|
public string? WebPushVapidPrivateKey { get; set; }
|
||||||
/// <summary>VAPID subject — mailto:/URL контакта оператора push (по умолчанию mailto:).</summary>
|
/// <summary>VAPID subject — mailto:/URL контакта оператора push (по умолчанию mailto:).</summary>
|
||||||
public string? WebPushVapidSubject { get; set; }
|
public string? WebPushVapidSubject { get; set; }
|
||||||
|
|
||||||
|
// ── Подгрузка из МойСклад (дельта-синхронизация на тестовый стенд) ─────────
|
||||||
|
// Переключатель, которым SuperAdmin включает/выключает автоматический перенос
|
||||||
|
// новых и изменённых документов из МойСклад на стенд (systemd-таймер раз в
|
||||||
|
// 10 мин, скрипт scripts/migration/delta_sync.py). МойСклад только читается.
|
||||||
|
// Скрипт спрашивает этот флаг у API и при «Выкл» сразу выходит, не трогая
|
||||||
|
// своё состояние (lastSync); эндпоинт подгрузки тоже отказывает при «Выкл».
|
||||||
|
/// <summary>Главный флаг дельта-подгрузки из МойСклад. По умолчанию ВЫКЛ.</summary>
|
||||||
|
public bool MoySkladDeltaSyncEnabled { get; set; }
|
||||||
|
|
||||||
|
// Состояние последнего прогона дельта-синхронизации — пишется самим скриптом
|
||||||
|
// (delta_sync.py) через /api/admin/moysklad/delta-sync/report, чтобы SuperAdmin
|
||||||
|
// видел результат на странице «Настройки платформы».
|
||||||
|
public DateTime? MoySkladDeltaLastRunStartedAt { get; set; }
|
||||||
|
public DateTime? MoySkladDeltaLastRunFinishedAt { get; set; }
|
||||||
|
/// <summary>Итог последнего прогона: true=OK, false=ошибка, null=ещё не было.</summary>
|
||||||
|
public bool? MoySkladDeltaLastRunOk { get; set; }
|
||||||
|
/// <summary>Сколько изменений перенесено в последнем прогоне.</summary>
|
||||||
|
public int? MoySkladDeltaLastRunMigrated { get; set; }
|
||||||
|
/// <summary>Текст ошибки последнего прогона (если был сбой).</summary>
|
||||||
|
public string? MoySkladDeltaLastRunError { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ protected override void OnModelCreating(ModelBuilder builder)
|
||||||
b.Property(x => x.SmtpUsername).HasMaxLength(200);
|
b.Property(x => x.SmtpUsername).HasMaxLength(200);
|
||||||
b.Property(x => x.FromEmail).HasMaxLength(200);
|
b.Property(x => x.FromEmail).HasMaxLength(200);
|
||||||
b.Property(x => x.FromName).HasMaxLength(200);
|
b.Property(x => x.FromName).HasMaxLength(200);
|
||||||
|
b.Property(x => x.MoySkladDeltaLastRunError).HasMaxLength(2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Entity<SuperAdminAuditLog>(b =>
|
builder.Entity<SuperAdminAuditLog>(b =>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Переключатель дельта-подгрузки из МойСклад + состояние последнего
|
||||||
|
/// прогона в platform_settings. Управляется SuperAdmin'ом через
|
||||||
|
/// /super-admin/platform-settings/moysklad-delta. По умолчанию ВЫКЛ.</summary>
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20261012010000_Phase46_MoySkladDeltaToggle")]
|
||||||
|
public partial class Phase46_MoySkladDeltaToggle : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
// Идемпотентно — ADD COLUMN IF NOT EXISTS, чтобы повтор применения
|
||||||
|
// (ручной SQL / соседняя миграция) не падал.
|
||||||
|
b.Sql(@"
|
||||||
|
ALTER TABLE public.platform_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS ""MoySkladDeltaSyncEnabled"" boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS ""MoySkladDeltaLastRunStartedAt"" timestamp with time zone NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS ""MoySkladDeltaLastRunFinishedAt"" timestamp with time zone NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS ""MoySkladDeltaLastRunOk"" boolean NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS ""MoySkladDeltaLastRunMigrated"" integer NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS ""MoySkladDeltaLastRunError"" varchar(2000) NULL;");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.Sql(@"
|
||||||
|
ALTER TABLE public.platform_settings
|
||||||
|
DROP COLUMN IF EXISTS ""MoySkladDeltaSyncEnabled"",
|
||||||
|
DROP COLUMN IF EXISTS ""MoySkladDeltaLastRunStartedAt"",
|
||||||
|
DROP COLUMN IF EXISTS ""MoySkladDeltaLastRunFinishedAt"",
|
||||||
|
DROP COLUMN IF EXISTS ""MoySkladDeltaLastRunOk"",
|
||||||
|
DROP COLUMN IF EXISTS ""MoySkladDeltaLastRunMigrated"",
|
||||||
|
DROP COLUMN IF EXISTS ""MoySkladDeltaLastRunError"";");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Save, Send, CheckCircle2, AlertTriangle, Mail } from 'lucide-react'
|
import { Save, Send, CheckCircle2, AlertTriangle, Mail, Database } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { Field, TextInput, TextArea, Checkbox } from '@/components/Field'
|
import { Field, TextInput, TextArea, Checkbox } from '@/components/Field'
|
||||||
|
|
@ -18,6 +18,15 @@ interface PlatformSettingsDto {
|
||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MoySkladDeltaDto {
|
||||||
|
enabled: boolean
|
||||||
|
lastRunStartedAt: string | null
|
||||||
|
lastRunFinishedAt: string | null
|
||||||
|
lastRunOk: boolean | null
|
||||||
|
lastRunMigrated: number | null
|
||||||
|
lastRunError: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface Form {
|
interface Form {
|
||||||
smtpHost: string
|
smtpHost: string
|
||||||
smtpPort: string
|
smtpPort: string
|
||||||
|
|
@ -56,6 +65,35 @@ export function SuperAdminPlatformSettingsPage() {
|
||||||
const [testBusy, setTestBusy] = useState(false)
|
const [testBusy, setTestBusy] = useState(false)
|
||||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null)
|
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null)
|
||||||
|
|
||||||
|
// Подгрузка из МойСклад (дельта-синхронизация).
|
||||||
|
const { data: delta } = useQuery({
|
||||||
|
queryKey: ['/api/super-admin/platform-settings/moysklad-delta'],
|
||||||
|
queryFn: async () => (await api.get<MoySkladDeltaDto>('/api/super-admin/platform-settings/moysklad-delta')).data,
|
||||||
|
})
|
||||||
|
const [deltaPending, setDeltaPending] = useState(false)
|
||||||
|
const [deltaLoaded, setDeltaLoaded] = useState(false)
|
||||||
|
const [deltaReason, setDeltaReason] = useState('')
|
||||||
|
const [deltaSaving, setDeltaSaving] = useState(false)
|
||||||
|
const [deltaSaved, setDeltaSaved] = useState(false)
|
||||||
|
const [deltaError, setDeltaError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (delta && !deltaLoaded) { setDeltaPending(delta.enabled); setDeltaLoaded(true) }
|
||||||
|
}, [delta, deltaLoaded])
|
||||||
|
|
||||||
|
const saveDelta = async () => {
|
||||||
|
setDeltaError(null); setDeltaSaving(true)
|
||||||
|
try {
|
||||||
|
await api.put('/api/super-admin/platform-settings/moysklad-delta', { enabled: deltaPending, reason: deltaReason })
|
||||||
|
await qc.invalidateQueries({ queryKey: ['/api/super-admin/platform-settings/moysklad-delta'] })
|
||||||
|
setDeltaReason('')
|
||||||
|
setDeltaSaved(true); setTimeout(() => setDeltaSaved(false), 2500)
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } }, message?: string }
|
||||||
|
setDeltaError(err.response?.data?.error ?? err.message ?? 'Не удалось сохранить')
|
||||||
|
} finally { setDeltaSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && !loaded) {
|
if (data && !loaded) {
|
||||||
setForm({
|
setForm({
|
||||||
|
|
@ -248,10 +286,72 @@ export function SuperAdminPlatformSettingsPage() {
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Hidden Checkbox import используется чтобы линтер не ругался — оставляем
|
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
|
||||||
чтобы при будущем расширении (например «Использовать SMTP-настройки
|
<h3 className="font-semibold flex items-center gap-2"><Database className="w-4 h-4" /> Подгрузка из МойСклад</h3>
|
||||||
организации вместо платформенных») было где включить тоггл. */}
|
<p className="text-xs text-slate-500">
|
||||||
<div className="hidden"><Checkbox label="" checked={false} onChange={() => {}} /></div>
|
Раз в 10 минут переносит новые и изменённые документы из МойСклад в магазин FOOD MARKET
|
||||||
|
на тестовом стенде. МойСклад только читается.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-slate-200 dark:border-slate-700 px-3 py-2">
|
||||||
|
<Checkbox
|
||||||
|
label={deltaPending ? 'Включена' : 'Выключена'}
|
||||||
|
checked={deltaPending}
|
||||||
|
onChange={setDeltaPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Состояние последнего прогона */}
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium text-slate-600 dark:text-slate-400 mb-1">Последний прогон</div>
|
||||||
|
{delta && (delta.lastRunStartedAt || delta.lastRunFinishedAt) ? (
|
||||||
|
<div className="space-y-0.5 text-slate-600 dark:text-slate-400">
|
||||||
|
<div>Начало: {delta.lastRunStartedAt ? new Date(delta.lastRunStartedAt).toLocaleString('ru-RU') : '—'}</div>
|
||||||
|
<div>Окончание: {delta.lastRunFinishedAt ? new Date(delta.lastRunFinishedAt).toLocaleString('ru-RU') : '—'}</div>
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
Итог:{' '}
|
||||||
|
{delta.lastRunOk == null ? '—' : delta.lastRunOk ? (
|
||||||
|
<span className="text-emerald-600 inline-flex items-center gap-1"><CheckCircle2 className="w-4 h-4" /> OK</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-600 inline-flex items-center gap-1"><AlertTriangle className="w-4 h-4" /> ошибка</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>Перенесено изменений: {delta.lastRunMigrated ?? 0}</div>
|
||||||
|
{delta.lastRunError && <div className="text-red-600">Ошибка: {delta.lastRunError}</div>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-slate-500">Прогонов ещё не было.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deltaError && (
|
||||||
|
<div className="rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-300">
|
||||||
|
{deltaError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Причина изменения (≥ 10 символов, в журнал)">
|
||||||
|
<TextArea rows={2} value={deltaReason}
|
||||||
|
onChange={(e) => setDeltaReason(e.target.value)}
|
||||||
|
placeholder="Например: включаю подгрузку из МойСклад на стенде для догона изменений перед переходом" />
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={saveDelta}
|
||||||
|
disabled={deltaSaving || deltaReason.trim().length < 10 || !delta || deltaPending === delta.enabled}
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" /> {deltaSaving ? 'Сохраняю…' : (deltaPending ? 'Включить подгрузку' : 'Выключить подгрузку')}
|
||||||
|
</Button>
|
||||||
|
{deltaSaved && (
|
||||||
|
<span className="text-sm text-emerald-600 inline-flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-4 h-4" /> Сохранено
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{delta && deltaPending === delta.enabled && (
|
||||||
|
<span className="text-xs text-slate-500">Текущее состояние: {delta.enabled ? 'Вкл' : 'Выкл'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue