A bejövő számlák szinkronja javítva, és kap egy admin felületet

- A parse a <reszlet> elemeket számlaként olvasta be. A TABLE_ID NEM egyedi a válaszon
  belül: a számlának, a tételnek és a részletfizetésnek is van sajátja, ugyanabból a
  kicsi számtartományból — élesben ez ütközött a számla saját azonosítójával, és az
  egyedi index kapta el. Az elem-felismerés mostantól szerkezeti: számla az, aminek van
  TABLE_ID gyereke és NINCS olyan őse, aminek szintén van. Névtől független, tehát egy
  jövőbeli negyedik gyerek-elem sem tud átcsúszni.
- A beszúrt sor bekerül a keresőszótárba, így egy batchen belül ismétlődő TABLE_ID
  frissítés lesz, nem újabb beszúrás.
- Soronkénti hibatűrés + "hibás" számláló: egy rossz sor eddig eldobta a batch
  maradékát, miközben a vízjel már elmozdult — a kimaradt sorok legközelebb sem jöttek
  volna vissza.
- Admin → Szállítmányok → Bejövő számlák: a tükör állapota (darabszám, lefedett időszak,
  vízjel, teljesítés-dátum nélküliek, félretettek) és a futtató gombok. Eddig a szinkron
  csak konzolból volt indítható.
- Sync?full=true: kényszerített teljes lekérdezés. A vízjel a TÁROLT adatból áll elő,
  tehát egy hiányosan indult tükör magától soha nem egészülne ki.
- Raw endpoint: az InnVoice nyers válasza. A szerkezetet a dokumentáció nem rögzíti, és
  egyszer már vakon találgattam.

Éles betöltés: 6551 számla, 12296 tétel, 0 hibás.
This commit is contained in:
2026-08-25 23:02:35 +02:00
parent a6860a977c
commit 40d9fa4331
6 changed files with 338 additions and 21 deletions
@@ -1,5 +1,6 @@
using LinqToDB;
using Microsoft.AspNetCore.Mvc;
using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Security;
@@ -19,9 +20,66 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
public class IncomingInvoiceController(
IPermissionService permissionService,
IncomingInvoiceSyncService syncService,
InnVoiceApiService apiService,
FruitBankDbContext dbContext)
: BaseAdminController
{
/// <summary>A szinkron felülete: a tükör állapota és a futtató gombok.</summary>
[HttpGet]
public async Task<IActionResult> Index()
{
if (!await permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
var invoices = dbContext.IncomingInvoices.GetAll();
var withDate = dbContext.IncomingInvoices.GetAll().Where(invoice => invoice.PerformanceDate != null);
var model = new IncomingInvoiceStatusModel
{
InvoiceCount = await AsyncExtensions.CountAsync(invoices),
ItemCount = await AsyncExtensions.CountAsync(dbContext.IncomingInvoiceItems.GetAll()),
Watermark = await dbContext.IncomingInvoices.GetLatestSourceUpdatedAtAsync(),
WithoutPerformanceDate = await AsyncExtensions.CountAsync(dbContext.IncomingInvoices.GetAllWithoutPerformanceDate()),
SetAsideCount = await AsyncExtensions.CountAsync(invoices.Where(invoice => invoice.IsSetAside)),
FirstPerformanceDate = await AsyncExtensions.MinAsync(withDate, invoice => invoice.PerformanceDate),
LastPerformanceDate = await AsyncExtensions.MaxAsync(withDate, invoice => invoice.PerformanceDate)
};
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/IncomingInvoice/Index.cshtml", model);
}
/// <summary>
/// Az InnVoice NYERS válasza, feldolgozás nélkül — a válasz szerkezetének megállapításához.
/// </summary>
/// <remarks>
/// A szolgáltató dokumentációja a mezőket felsorolja, a befoglaló elemek nevét nem. Az első éles futáson
/// emiatt a tétel-elemek is számlaként parse-olódtak. Ez az endpoint azért van, hogy a szerkezetet ne
/// találgatni kelljen. Olvas, tehát GET — de a szolgáltató kvótájából fogyaszt (id nélkül óránként 20).
/// <para><paramref name="full"/> = true → a TELJES lekérdezés (szűk kvóta); egyébként a tárolt vízjeltől
/// kért növekményes válasz, vagyis pontosan az, amit a szinkron is látna.</para>
/// </remarks>
[HttpGet]
public async Task<IActionResult> Raw(bool full = false, int maxChars = 8000)
{
if (!await permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
try
{
var since = full ? null : await dbContext.IncomingInvoices.GetLatestSourceUpdatedAtAsync();
var raw = await apiService.GetIncomingInvoicesRawAsync(since);
return Content(
$"--- kérés: {(full ? "TELJES" : $"updatedtime {since:yyyy-MM-dd HH:mm:ss}")} · teljes hossz: {raw.Length} karakter ---\r\n\r\n" +
(raw.Length > maxChars ? raw[..maxChars] + "\r\n\r\n--- levágva ---" : raw),
"text/plain; charset=utf-8");
}
catch (Exception ex)
{
return Content($"HIBA: {ex.Message}", "text/plain; charset=utf-8");
}
}
/// <summary>A tükör állapota — futtatás nélkül megmutatja, hol tart a szinkron.</summary>
[HttpGet]
public async Task<IActionResult> Status()
@@ -54,15 +112,19 @@ public class IncomingInvoiceController(
/// és a túllépést NEM hibával jelzi, hanem csendben kevesebb adattal — ezért ez kézi művelet marad, amíg
/// az ütemezése nincs végiggondolva.
/// </remarks>
/// <param name="full">
/// Kényszerített teljes lekérdezés a vízjeltől függetlenül — menekülőút hiányos tükörhöz. Fogyaszt a
/// 30 naponta 10-es keretből, ezért kézzel, tudatosan.
/// </param>
[HttpPost]
public async Task<IActionResult> Sync()
public async Task<IActionResult> Sync(bool full = false)
{
if (!await permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
try
{
var result = await syncService.SyncAsync();
var result = await syncService.SyncAsync(full);
return Json(new
{
@@ -71,7 +133,8 @@ public class IncomingInvoiceController(
fetched = result.FetchedCount,
inserted = result.InsertedCount,
updated = result.UpdatedCount,
skipped = result.SkippedCount
skipped = result.SkippedCount,
failed = result.FailedCount
});
}
catch (Exception ex)
@@ -0,0 +1,27 @@
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
/// <summary>A bejövő számla tükör állapota — a szinkron-felület fejléce.</summary>
public class IncomingInvoiceStatusModel
{
/// <summary>Tükrözött számlák száma.</summary>
public int InvoiceCount { get; set; }
/// <summary>Tükrözött számla-tételek száma.</summary>
public int ItemCount { get; set; }
/// <summary>
/// A növekményes szinkron vízjele: a tárolt <c>SourceUpdatedAt</c> maximuma.
/// </summary>
/// <remarks><c>null</c> = üres tükör, tehát a következő szinkron TELJES lekérdezést indít.</remarks>
public DateTime? Watermark { get; set; }
/// <summary>Teljesítés-dátum nélküli számlák: ezek EGYIK hónapba sem esnek bele.</summary>
public int WithoutPerformanceDate { get; set; }
/// <summary>Félretett (nem lezárt) számlák — megjelennek, de jelölve.</summary>
public int SetAsideCount { get; set; }
/// <summary>A legkorábbi és a legkésőbbi teljesítés-dátum a tükörben — egy pillantásra látszik a lefedettség.</summary>
public DateTime? FirstPerformanceDate { get; set; }
public DateTime? LastPerformanceDate { get; set; }
}
@@ -0,0 +1,164 @@
@using Nop.Web.Framework.Infrastructure
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.IncomingInvoiceStatusModel
@{
ViewBag.PageTitle = "Bejövő számlák (InnVoice)";
NopHtml.SetActiveMenuItemSystemName("FruitBank");
}
@* A nop admin minden POST-ot antiforgery-tokennel véd — enélkül a szinkron 400-at adna. *@
@Html.AntiForgeryToken()
<div class="content-header clearfix">
<h1 class="float-left">Bejövő számlák · InnVoice tükör</h1>
</div>
<section class="content">
<div class="container-fluid">
<div class="card card-default mb-2">
<div class="card-body py-2">
<p class="mb-1 text-muted">
A szállítói számlákat az admin az <strong>InnVoice-ba</strong> rögzíti; innen tükrözzük őket a havi
ÁFA-kimutatáshoz. A hónapba a <strong>teljesítés kelte</strong> sorolja a számlát.
</p>
<p class="mb-0 text-muted">
⚠️ A szolgáltató kvótája szűk: <strong>teljes lekérdezésből 30 naponta 10</strong>, id nélküli hívásból
óránként 20. A túllépést <strong>nem hibával</strong> jelzi, hanem csendben kevesebb adattal — ezért a
teljes lekérdezés csak üres tükörnél fut, egyébként a vízjeltől kérünk változásokat.
</p>
</div>
</div>
@* ── A tükör állapota ───────────────────────────────────────── *@
<div class="card card-default mb-2">
<div class="card-header"><strong>A tükör állapota</strong></div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr>
<td style="width:280px;">Tükrözött számla</td>
<td><strong>@Model.InvoiceCount</strong></td>
</tr>
<tr>
<td>Tükrözött tétel</td>
<td><strong>@Model.ItemCount</strong></td>
</tr>
<tr>
<td>Lefedett időszak (teljesítés)</td>
<td>
@if (Model.FirstPerformanceDate.HasValue)
{
<text>@Model.FirstPerformanceDate.Value.ToString("yyyy.MM.dd.") @Model.LastPerformanceDate!.Value.ToString("yyyy.MM.dd.")</text>
}
else
{
<span class="text-muted">—</span>
}
</td>
</tr>
<tr>
<td>Vízjel (eddig szinkronizálva)</td>
<td>
@if (Model.Watermark.HasValue)
{
<text>@Model.Watermark.Value.ToString("yyyy.MM.dd. HH:mm:ss")</text>
}
else
{
<span class="text-warning"><strong>üres</strong> — a következő szinkron TELJES lekérdezést indít</span>
}
</td>
</tr>
<tr>
<td>Teljesítés-dátum nélkül</td>
<td>
@if (Model.WithoutPerformanceDate > 0)
{
<span class="text-danger"><strong>@Model.WithoutPerformanceDate</strong> — ezek egyik hónapba sem esnek bele</span>
}
else
{
<text>0</text>
}
</td>
</tr>
<tr>
<td>Félretett</td>
<td>@Model.SetAsideCount</td>
</tr>
</table>
</div>
</div>
@* ── Műveletek ──────────────────────────────────────────────── *@
<div class="card card-default mb-2">
<div class="card-header"><strong>Műveletek</strong></div>
<div class="card-body">
<button type="button" id="btnSync" class="btn btn-primary">
<i class="fas fa-sync"></i> Szinkronizálás
</button>
<button type="button" id="btnFullSync" class="btn btn-secondary ml-2">
<i class="fas fa-history"></i> Teljes újraszinkron
</button>
<a href="@Url.Action("Raw", "IncomingInvoice", new { area = "Admin" })" target="_blank" class="btn btn-light ml-2">
<i class="fas fa-code"></i> Nyers válasz
</a>
<p class="text-muted mt-2 mb-0">
A <strong>Teljes újraszinkron</strong> a vízjeltől függetlenül mindent újra lekér — csak akkor, ha a
tükörről kiderül, hogy hiányos. Fogyaszt a 30 naponta 10-es keretből.
</p>
<div id="syncResult" class="mt-3"></div>
</div>
</div>
</div>
</section>
@* Sima script, ahogy a szomszédos admin nézetek is (ShippingCost/Index.cshtml) — nem az asp-location
tag-helperrel, hogy ne függjön a plugin nézet-importjaitól. *@
<script>
(function () {
function token() {
var el = document.querySelector('input[name="__RequestVerificationToken"]');
return el ? el.value : '';
}
function run(full, button) {
var box = document.getElementById('syncResult');
var buttons = [document.getElementById('btnSync'), document.getElementById('btnFullSync')];
buttons.forEach(function (b) { b.disabled = true; });
box.innerHTML = '<div class="alert alert-info mb-0">Szinkron fut… nagy állománynál ez percekig tarthat.</div>';
fetch('@Url.Action("Sync", "IncomingInvoice", new { area = "Admin" })' + (full ? '?full=true' : ''), {
method: 'POST',
headers: { 'RequestVerificationToken': token() }
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) {
box.innerHTML = '<div class="alert alert-danger mb-0"><strong>Hiba:</strong> ' + (d.message || 'ismeretlen') + '</div>';
return;
}
box.innerHTML = '<div class="alert alert-success mb-0">'
+ '<strong>Kész.</strong> Lekérve: ' + d.fetched
+ ' · új: ' + d.inserted
+ ' · frissítve: ' + d.updated
+ ' · kihagyva: ' + d.skipped
+ ' · hibás: ' + d.failed
+ (d.wasFullLoad ? ' · <em>teljes lekérdezés volt</em>' : '')
+ '<br/><a href="javascript:location.reload()">Állapot frissítése</a></div>';
})
.catch(function (e) {
box.innerHTML = '<div class="alert alert-danger mb-0"><strong>Hálózati hiba:</strong> ' + e + '</div>';
})
.finally(function () { buttons.forEach(function (b) { b.disabled = false; }); });
}
document.getElementById('btnSync').addEventListener('click', function () { run(false, this); });
document.getElementById('btnFullSync').addEventListener('click', function () {
if (confirm('Teljes újraszinkron: mindent újra lekér, és fogyaszt a 30 naponta 10-es keretből. Biztosan?')) run(true, this);
});
})();
</script>
@@ -293,6 +293,17 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
shippingConfigurationItem.ChildNodes.Insert(7, marginReportMenuItem);
var incomingInvoiceMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "FruitBank",
Title = "Bejövő számlák",
IconClass = "fas fa-file-invoice",
Url = _adminMenu.GetMenuItemUrl("IncomingInvoice", "Index")
};
shippingConfigurationItem.ChildNodes.Insert(8, incomingInvoiceMenuItem);
var preordersRootMenuItem = new AdminMenuItem
{
Visible = true,
@@ -51,9 +51,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
/// <summary>
/// Lefuttatja a szinkront: üres táblánál teljes feltöltés, egyébként a vízjel óta módosultak.
/// </summary>
public async Task<IncomingInvoiceSyncResult> SyncAsync()
/// <param name="forceFullLoad">
/// Kényszerített teljes lekérdezés a vízjeltől függetlenül. Menekülőút, ha a tükörről kiderül, hogy
/// hiányos: a vízjel a TÁROLT adatból áll elő, tehát egy hiányosan indult tükör magától soha nem
/// egészülne ki. Tudatos, kézi művelet — a teljes lekérdezés kvótája 30 naponta 10.
/// </param>
public async Task<IncomingInvoiceSyncResult> SyncAsync(bool forceFullLoad = false)
{
var watermark = await _invoices.GetLatestSourceUpdatedAtAsync();
var storedWatermark = await _invoices.GetLatestSourceUpdatedAtAsync();
var watermark = forceFullLoad ? null : storedWatermark;
var result = new IncomingInvoiceSyncResult { WasFullLoad = watermark == null };
List<InnVoiceIncomingInvoice> fetched;
@@ -95,25 +101,43 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
continue;
}
if (existingByTableId.TryGetValue(source.TableId, out var existing))
// Soronkénti hibatűrés: egy rossz számla nem viheti el a maradékot. Hibatűrés nélkül az első
// ütközés eldobta a batch hátralévő részét, és a féloldalas állapot bent maradt — a vízjel
// viszont már elmozdult, tehát a kimaradt sorok legközelebb sem jöttek volna vissza.
try
{
ApplySource(existing, source);
await _invoices.UpdateAsync(existing);
await ReplaceItemsAsync(existing.Id, source);
result.UpdatedCount++;
if (existingByTableId.TryGetValue(source.TableId, out var existing))
{
ApplySource(existing, source);
await _invoices.UpdateAsync(existing);
await ReplaceItemsAsync(existing.Id, source);
result.UpdatedCount++;
}
else
{
var created = new IncomingInvoice();
ApplySource(created, source);
await _invoices.InsertAsync(created);
await ReplaceItemsAsync(created.Id, source);
result.InsertedCount++;
// A szótár a ciklus ELŐTT készült; a most beszúrt sort fel kell venni bele. Enélkül egy
// batchen belül ismétlődő TABLE_ID másodszor is beszúrásnak indulna, és az egyedi index
// eldobná — pontosan ez bukott el az első éles futáson.
existingByTableId[created.InnVoiceTableId] = created;
}
}
else
catch (Exception ex)
{
var created = new IncomingInvoice();
ApplySource(created, source);
await _invoices.InsertAsync(created);
await ReplaceItemsAsync(created.Id, source);
result.InsertedCount++;
result.FailedCount++;
_logger.Error($"IncomingInvoiceSync: a(z) {source.TableId} TABLE_ID-jű számla feldolgozása nem sikerült " +
$"(szállító: {source.SupplierName}, bizonylat: {source.DocumentNumber}).", ex);
}
}
_logger.Info($"IncomingInvoiceSync kész; lekérve: {result.FetchedCount}, új: {result.InsertedCount}, " +
$"frissítve: {result.UpdatedCount}, kihagyva: {result.SkippedCount}, teljes lekérdezés: {result.WasFullLoad}.");
$"frissítve: {result.UpdatedCount}, kihagyva: {result.SkippedCount}, hibás: {result.FailedCount}, " +
$"teljes lekérdezés: {result.WasFullLoad}.");
return result;
}
@@ -194,5 +218,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
/// <summary>TABLE_ID nélküli, ezért nem tükrözhető sorok.</summary>
public int SkippedCount { get; set; }
/// <summary>Feldolgozás közben elszállt sorok. A futás nem áll meg tőlük, de a szám nem hallgatható el.</summary>
public int FailedCount { get; set; }
}
}
@@ -58,6 +58,25 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
return invoices.FirstOrDefault();
}
/// <summary>
/// A NYERS válasz, feldolgozás nélkül — diagnosztikához.
/// </summary>
/// <remarks>
/// Azért van, mert az elemek felismerését egyszer már vakon találgattam, és rosszat találtam el: a
/// tétel-elemek is számlaként parse-olódtak. A szerkezetet a dokumentáció nem rögzíti, tehát látni kell.
/// <para><paramref name="updatedSince"/> = null → a TELJES lekérdezés (szűkös kvóta!), egyébként a
/// növekményes út — ez utóbbi ugyanazt a választ adja, amit a szinkron látna.</para>
/// </remarks>
public async Task<string> GetIncomingInvoicesRawAsync(DateTime? updatedSince)
{
var url = updatedSince == null
? $"{_baseUrl}/{_companyName}/incominginvoice"
: $"{_baseUrl}/{_companyName}/incominginvoice/updatedtime/{updatedSince.Value.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}";
var response = await _httpClient.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}
private async Task<List<InnVoiceIncomingInvoice>> GetIncomingInvoicesFromUrlAsync(string url)
{
try
@@ -81,11 +100,17 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
{
var document = XDocument.Parse(xml);
// A gyökér elem nevét a dokumentáció nem rögzíti, a mezőnevek viszont igen — ezért a TARTALOMRA
// szűrünk: az az elem számla, amelyiknek van TABLE_ID gyereke. Így egy "invoice"/"incominginvoice"
// elnevezés-váltás nem üres listát ad, hanem továbbra is működik.
// A válasz szerkezete: <response> → <invoice> → benne a fejmezők, majd <tetel>, <reszlet> és
// <attachment> gyerekek. A TABLE_ID NEM egyedi a dokumentumon belül: a számlának, a tételnek és a
// részlet-fizetésnek is van sajátja, ugyanabból a kicsi számtartományból.
//
// ⚠️ Ezért a számla az az elem, aminek van TABLE_ID gyereke ÉS NINCS olyan őse, aminek szintén van.
// Az első változat név szerint zárta ki a "tetel"-t — a <reszlet> viszont átcsúszott rajta, és
// számlaként került be. Élesben ez ütközött a számla saját TABLE_ID-jával (1-es és 2-es érték), és
// az egyedi index kapta el. A szerkezeti szabály névtől független: egy befoglalt elem soha nem számla.
var invoiceElements = document.Descendants()
.Where(element => element.Element("TABLE_ID") != null && element.Name.LocalName != "tetel");
.Where(element => element.Element("TABLE_ID") != null
&& !element.Ancestors().Any(ancestor => ancestor.Element("TABLE_ID") != null));
return [.. invoiceElements.Select(ParseIncomingInvoice)];
}