8 Commits
Author SHA1 Message Date
Adam 1c091ef71d Merge branch 'FruitBank_v0.0.8.2' of https://git.aycode.com/Adam/Mango.Nop.Plugins into FruitBank_v0.0.8.2 2026-08-26 15:55:20 +02:00
Adam 8207ec5c36 A csatolmány megjelenik a nézőben, nem letöltődik
Az InnVoice a PDF-re is application/octet-stream-et küld, és a forrás fejlécét
változatlanul továbbadva a böngésző letöltötte a fájlt, az iframe pedig üres maradt.

- a kiszolgált tartalomtípus a saját fájlnevünkből, majd a tartalom első bájtjaiból áll elő; a forrás fejléce csak utolsó esély, és csak ha mond is valamit
- kimondott Content-Disposition: inline — a fájlnév elhagyása önmagában nem elég, a böngésző a típusra hallgat
- az iframe flexre állt a modálisban: százalékos magassággal egy flex-szülőben nulla magas maradt
- ?raw=1 diagnosztika: a forrás fejlécei, a méret és az első bájtok szövegként — ezt a szolgáltató dokumentációjából nem lehet kikövetkeztetni
- a doksi a nem működő magyarázatot rögzítette, javítva
2026-08-26 15:43:18 +02:00
Adam e195f75cd9 Az InnVoice dokumentáció topic-mappába kerül, INNV topic-kóddal
- docs/INNVOICE.md → docs/INNVOICE/README.md, a három SQL runbook mellé
- INNVOICE_INCOMING.md: a tükör indoka, a vízjel, a TABLE_ID szerkezeti felismerése, a tárolás buktatói és az ÁFA-kimutatás szabályai
- a "Planned migration" szakasz a dokumentumból a TODO-ba kerül: jövő idő nem doksi-tartalom
- INNVOICE_ISSUES.md: az árva számla-JS és a degradált első betöltés miatt beragadó vízjel
- INNVOICE_TODO.md: ütemezett szinkron és a közvetlen piszkozat-számla beküldés
- INNV topic-kód a .github/TOPIC_CODES.md-ben, mindkét irányra
2026-08-26 15:20:09 +02:00
Adam 82cc1515ba Havi ÁFA-kimutatás a tükrözött bejövő számlákból
- VatReportService: havi, devizánkénti és áfakulcs szerinti bontás; a fejléc-végösszeg marad az irányadó, a kulcs-vödrök pontosan arra összegződnek
- admin ÁFA-kimutatás felület: KPI-blokk, becsukható panelek, szöveg/deviza szűrő a tételes listán, CSV-export
- alsó adathatár 2026-08-01: korábbi hónapra a kimenő oldal még nincs meg, és a hiányzó adat hiányzó áfának látszana
- árfolyam nélküli devizás számla nem kerül a forintos összegbe, hanem külön listára — a hiányt soha nem pótoljuk 1-gyel
- a bejövő számla tükre árfolyammal és az első csatolmánnyal bővül (fájlnév, URL, darabszám)
- a csatolmány saját proxy-végponton át, felugró ablakban nyílik meg: az InnVoice attachmentként küldi, és az URL bérlő-szintű tokent hordoz
- a CSV-be csak a csatolmány fájlneve kerül, a tokent hordozó URL nem
2026-08-26 15:20:01 +02:00
Adam 40d9fa4331 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.
2026-08-25 23:02:35 +02:00
Adam a6860a977c Az InnVoice bejövő számlái tükrözve, a havi ÁFA-kimutatás bemenetének
- Séma (kézi script): fbIncomingInvoice + fbIncomingInvoiceItem. A tételek egyetlen
  célt szolgálnak, az áfakulcsonkénti bontást — egy magyar számlán keveredhet a 27% és
  az 5%/0%, amit a fej egyetlen összege nem tud leképezni.
- A tükör az InnVoice TABLE_ID-jára kulcsol, EGYEDI indexszel: itt a duplikátum nem
  kellemetlenség, hanem hamis szám, mert a havi összeg együtt adódna össze vele.
- A vízjel maga az adat: a következő lekérdezés a tárolt SourceUpdatedAt maximumától
  kér, külön "utoljára szinkronizálva" beállítás nélkül. Egy féloldalas futás így nem
  ugrik át számlákat — ami nem került be, az a maximumot sem tolta el.
- Teljes lekérdezés kizárólag üres tükörnél. A szolgáltató kvótája (30 naponta 10) a
  túllépést NEM hibával jelzi, hanem csendben kevesebb adattal; utána mindig a
  növekményes út megy, egy perc biztonsági ráhagyással.
- Minden szám és dátum invariáns kultúrával olvasva: a szerver magyar területi
  beállítással is futhat, ahol a tizedespont félrecsúszna.
- Az XML-ben tartalom szerint keressük a számlákat (van-e TABLE_ID gyerek), nem a
  gyökér elem neve szerint — egy elnevezés-váltás így nem üres listát adna.
- Admin: Status (GET) a tükör állapotáról, Sync (POST) a futtatásra. A Sync azért POST,
  mert ír és kvótát fogyaszt.

A séma-scriptet a deploy ELŐTT le kell futtatni, különben a LinqToDB
"Invalid column name"-mel áll meg.
2026-08-25 20:19:18 +02:00
Adam 8f64252227 Az InnVoice hozzáférés beállításba kerül, a forráskódból ki
- FruitBankSettings: InnVoiceCompanyName / Username / Password / BaseUrl, és a hozzájuk
  tartozó szakasz a plugin konfigurációs lapján (a jelszó password-mezőben).
- Mindkét InnVoice service a beállításokból olvas, nem a konstruktora alapértékéből.
  Egy titok nem lehet fordítási idejű konstans: nem cserélhető újrafordítás nélkül, és
  mindenki látja, aki a repóhoz fér.
- Hiányzó beállításnál beszédes kivétel, nem visszaesés a régi értékre. Üres
  felhasználónévvel a hívás 401-et kapna, és a hibaüzenet a hálózatról szólna, nem a
  hiányzó beállításról. A tartalék meghagyása nem áttenné a titkot, hanem megduplázná.
- A beolvasás közös szabályai egy helyen (InnVoiceCredentials): a kimenő és a bejövő
  oldal ugyanazt a hozzáférést használja.

DEPLOY UTÁN: az InnVoice hozzáférést be kell írni az admin felületen (Bővítmények →
FruitBank plugin → Konfigurálás → InnVoice), különben a rendelés-beküldés leáll.
2026-08-25 20:04:05 +02:00
Adam 608ed443a6 A tervező felrakodási ország szerint tervez, és nem hagy ki rakományt a dátumhatáron
- Az ország a szállítólevélhez kapcsolt TELEPHELYRŐL jön, nem a partner székhelyéről:
  egy lengyel cégnek lehet holland felrakodó telephelye, és a kamion oda megy. Telephely
  híján a partner egyetlen telephelye, több telephelynél a székhely — a forrást a
  PlannerItem.CountrySource hordozza, hogy a felület jelezhesse a hiányzó adatot.
  A telephelyek külön lekérdezéssel jönnek: a PartnerDepot nav szándékosan nincs benne
  egyetlen alapértelmezett loadRelations-ben sem.
- A dátumablak három, egymásra épülő lépésben választ halmazt a korábbi két független
  szűrés helyett. Így a határon belüli kamion nem látszik üresen attól, hogy régebbi
  dokumentumról hozza az áruját, és nem marad allocation, aminek nincs hol megjelennie.
  Egy lépés, nem tranzitív lezárás.
- Az AutoPlanShipments nem pakol a határon kívülről behúzott kamionokra: azok
  megjelenítés miatt vannak a modellben, nem tervezésre.
- A kapacitás-szabály a ShippingCapacityHelperből jön, nem másolatból.
2026-08-25 16:52:24 +02:00
38 changed files with 3265 additions and 71 deletions
+1
View File
@@ -16,4 +16,5 @@ Per-repo topic registry for this plugin, per the **per-repo extension convention
| `ORDD` | ORDER-DRAFT | AI-assisted intake channel: inbound free-text messages (Viber/email/dictation) parsed into order drafts, admin review/approval, then PreOrder creation that feeds the existing PRE-ORDER conversion. Covers parsing, product/partner resolution, learning mappings, the draft lifecycle and the approval entry point. | `docs/ORDERDRAFT/` |
| `MARGIN`| ÁRRÉS | Beszerzési önköltség és árrés: a rendelés-tételek ↔ beszerzések (költség-rétegek) hozzárendelése, a fuvar- és egyéb költségek tárolása és szétosztása, deviza- és áfakezelés az árrés szempontjából, valamint a kimutatás dokumentum / kamion / hónap bontásban. | `docs/MARGIN/` |
| `PMDATA`| TERMÉK-TÖRZSADAT | A termék mérési/logisztikai törzsadata (`IsMeasurable`, `Tare`, `AverageWeight`, `AverageWeightTreshold`, `CratesPerPallet`): tárolása, írási útjai, validálása és tömeges szerkesztése. A készlet-vezérelt értékek (`NetWeight`, `IncomingQuantity`) NEM ide tartoznak — azok a `STOCK` és a `PREO` topiké. | `docs/PMDATA/` |
| `INNV` | INNVOICE | Az InnVoice számlázó-rendszer integrációja, mindkét irányban: a kimenő rendelés-/számla-beküldés az admin rendelés-oldalról, és a szállítói (bejövő) számlák tükrözése, valamint az arra épülő havi ÁFA-kimutatás. A hozzáférés és a kvóta közös, ezért egy topik. A NAV-adatszolgáltatás NEM ide tartozik — az az `EKAER` topiké. | `docs/INNVOICE/` |
| `NOTIF` | ÉRTESÍTÉS | Vevői e-mail értesítők: a plugin saját `MessageTemplate`-jei, a nop token-provider kiváltása és a saját tokenek, a `MeasuringStatus`-átmenetekhez kötött kiváltás, valamint az ismétlés elleni jelölés. A nop natív értesítői NEM ide tartoznak. | `docs/NOTIFICATION/` |
@@ -36,7 +36,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
Temperature = _settings.Temperature,
RequestTimeoutSeconds = _settings.RequestTimeoutSeconds,
ZaiApiKey = _settings.ZaiApiKey,
ZaiModel = _settings.ZaiModel
ZaiModel = _settings.ZaiModel,
InnVoiceCompanyName = _settings.InnVoiceCompanyName,
InnVoiceUsername = _settings.InnVoiceUsername,
InnVoicePassword = _settings.InnVoicePassword,
InnVoiceBaseUrl = _settings.InnVoiceBaseUrl
};
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Configure/Configure.cshtml", model);
}
@@ -62,6 +66,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
_settings.RequestTimeoutSeconds = model.RequestTimeoutSeconds;
_settings.ZaiApiKey = model.ZaiApiKey ?? string.Empty;
_settings.ZaiModel = model.ZaiModel ?? "glm-ocr";
_settings.InnVoiceCompanyName = model.InnVoiceCompanyName ?? string.Empty;
_settings.InnVoiceUsername = model.InnVoiceUsername ?? string.Empty;
_settings.InnVoicePassword = model.InnVoicePassword ?? string.Empty;
_settings.InnVoiceBaseUrl = string.IsNullOrWhiteSpace(model.InnVoiceBaseUrl)
? "https://api.innvoice.hu"
: model.InnVoiceBaseUrl.Trim();
// Save settings
await _settingService.SaveSettingAsync(_settings);
@@ -0,0 +1,260 @@
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;
using Nop.Web.Areas.Admin.Controllers;
using Nop.Web.Framework;
using Nop.Web.Framework.Mvc.Filters;
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
/// <summary>Az InnVoice-ból tükrözött bejövő (szállítói) számlák — a havi ÁFA-kimutatás bejövő oldala.</summary>
/// <remarks>
/// Egyelőre csak a szinkron indítása és az állapot kiírása. A havi kimutatás külön lépés — ez az endpoint azért
/// van itt már most, hogy a tükrözés a valós fiókon ellenőrizhető legyen, mielőtt riport épül rá.
/// </remarks>
[Area(AreaNames.ADMIN)]
[AuthorizeAdmin]
public class IncomingInvoiceController(
IPermissionService permissionService,
IncomingInvoiceSyncService syncService,
InnVoiceApiService apiService,
IHttpClientFactory httpClientFactory,
FruitBankDbContext dbContext)
: BaseAdminController
{
/// <summary>
/// Egy tükrözött számla csatolmánya (a beszkennelt bizonylat), a saját kiszolgálónkon át.
/// </summary>
/// <remarks>
/// Miért proxy, és nem közvetlen link?
/// <list type="bullet">
/// <item>Az InnVoice <c>Content-Disposition: attachment</c>-tel adja vissza, tehát a böngésző LETÖLTI a
/// fájlt ahelyett, hogy megjelenítené. Itt <c>inline</c>-ként adjuk tovább, így beágyazható.</item>
/// <item>A beágyazott néző (iframe/embed) más origint nem tud megjeleníteni korlátozás nélkül; a saját
/// kiszolgálónkról jövő tartalom viszont mindig megjelenik.</item>
/// <item>Az InnVoice URL bérlő-szintű hozzáférési tokent tartalmaz. Proxyval az a token NEM kerül ki a
/// böngészőbe, a lap forrásába és a böngésző-előzményekbe.</item>
/// </list>
/// <para>Nem nyílt proxy: az URL nem paraméter, hanem a saját adatbázisunkból, a számla azonosítója
/// alapján jön — más címre nem irányítható.</para>
/// </remarks>
/// <param name="raw">
/// Diagnosztika: a válasz helyett a FORRÁS fejléceit adja vissza szövegként. Azért van, mert a megjelenítés
/// azon múlik, amit a szolgáltató a tartalomtípusnak mond, és azt találgatni egy kör oda-vissza.
/// </param>
[HttpGet]
public async Task<IActionResult> Attachment(int id, bool raw = false)
{
if (!await permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
var invoice = await dbContext.IncomingInvoices.GetByIdAsync(id);
if (invoice == null || string.IsNullOrWhiteSpace(invoice.AttachmentUrl))
return NotFound("Ehhez a számlához nincs tárolt csatolmány.");
try
{
var client = httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(60);
using var response = await client.GetAsync(invoice.AttachmentUrl);
if (!response.IsSuccessStatusCode)
return StatusCode((int)response.StatusCode,
$"Az InnVoice nem adta vissza a csatolmányt ({(int)response.StatusCode}).");
var bytes = await response.Content.ReadAsByteArrayAsync();
var upstreamType = response.Content.Headers.ContentType?.MediaType;
if (raw)
return Content(
$"forrás Content-Type: {upstreamType ?? "(nincs)"}\r\n" +
$"forrás Content-Disposition: {response.Content.Headers.ContentDisposition?.ToString() ?? "(nincs)"}\r\n" +
$"méret: {bytes.Length} bájt\r\n" +
$"tárolt fájlnév: {invoice.AttachmentFileName ?? "(nincs)"}\r\n" +
$"első bájtok: {BitConverter.ToString(bytes.Take(8).ToArray())}\r\n" +
$"kiszolgált Content-Type: {ResolveContentType(invoice.AttachmentFileName, upstreamType, bytes)}",
"text/plain; charset=utf-8");
// A diszpozíciót KIMONDJUK. Fájlnév elhagyásával az ASP.NET egyáltalán nem küld fejlécet, ami
// elvben inline-t jelent — a gyakorlatban a böngésző a tartalomtípusra hallgat, és letölt, ha az
// nem megjeleníthető. A kettő együtt kell.
Response.Headers.ContentDisposition = "inline";
return File(bytes, ResolveContentType(invoice.AttachmentFileName, upstreamType, bytes));
}
catch (Exception ex)
{
return StatusCode(502, $"A csatolmány letöltése nem sikerült: {ex.Message}");
}
}
/// <summary>A kiszolgált tartalomtípus: fájlnév, majd tartalom, és csak utolsóként a forrás fejléce.</summary>
/// <remarks>
/// A sorrend nem esztétikai. Az InnVoice <c>application/octet-stream</c>-et küld a PDF-re is, és azt
/// továbbadva a böngésző letölti a fájlt ahelyett, hogy megjelenítené — pont ezért készült ez a proxy.
/// A saját fájlnevünk és a tartalom első bájtjai megbízhatóbbak, mint a forrás fejléce.
/// </remarks>
private static string ResolveContentType(string fileName, string upstreamContentType, byte[] content)
{
var fromName = GuessContentType(fileName);
if (fromName != FallbackContentType)
return fromName;
var fromContent = SniffContentType(content);
if (fromContent != null)
return fromContent;
// A forrás fejléce csak akkor ér valamit, ha mond is valamit: az általános bináris típus nem mond.
return string.IsNullOrWhiteSpace(upstreamContentType) || upstreamContentType == FallbackContentType
? FallbackContentType
: upstreamContentType;
}
/// <summary>Tartalomtípus az első bájtokból; null, ha nem ismerjük fel.</summary>
private static string SniffContentType(byte[] content) => content switch
{
[0x25, 0x50, 0x44, 0x46, ..] => "application/pdf", // %PDF
[0x89, 0x50, 0x4E, 0x47, ..] => "image/png", // .PNG
[0xFF, 0xD8, 0xFF, ..] => "image/jpeg",
[0x49, 0x49, 0x2A, 0x00, ..] or [0x4D, 0x4D, 0x00, 0x2A, ..] => "image/tiff",
_ => null
};
/// <summary>Amit akkor mondunk, ha semmi nem árulja el a típust — ezt a böngésző letölti.</summary>
private const string FallbackContentType = "application/octet-stream";
/// <summary>Tartalomtípus a fájlnévből, ha a forrás nem mondja meg.</summary>
private static string GuessContentType(string fileName) => (fileName ?? string.Empty).ToLowerInvariant() switch
{
var name when name.EndsWith(".pdf") => "application/pdf",
var name when name.EndsWith(".png") => "image/png",
var name when name.EndsWith(".jpg") || name.EndsWith(".jpeg") => "image/jpeg",
var name when name.EndsWith(".tif") || name.EndsWith(".tiff") => "image/tiff",
_ => FallbackContentType
};
/// <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()
{
if (!await permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
var invoices = dbContext.IncomingInvoices.GetAll();
// A CountAsync a nop és a LinqToDB bővítménye közt kétértelmű ebben a névtér-készletben, ezért a
// LinqToDB-t nevezzük meg — a lekérdezések ennek a szolgáltatóján futnak.
return Json(new
{
success = true,
invoiceCount = await AsyncExtensions.CountAsync(invoices),
itemCount = await AsyncExtensions.CountAsync(dbContext.IncomingInvoiceItems.GetAll()),
// A vízjel: innen folytatja a következő növekményes lekérdezés. Üresnél teljes feltöltés jön.
watermark = await dbContext.IncomingInvoices.GetLatestSourceUpdatedAtAsync(),
// Teljesítés-dátum nélküli sor egyik hónapba sem esik bele — a kimutatás előtt látni kell, van-e ilyen.
withoutPerformanceDate = await AsyncExtensions.CountAsync(dbContext.IncomingInvoices.GetAllWithoutPerformanceDate()),
setAsideCount = await AsyncExtensions.CountAsync(invoices.Where(invoice => invoice.IsSetAside))
});
}
/// <summary>
/// Szinkron indítása: üres tükörnél teljes feltöltés, egyébként a vízjel óta módosultak.
/// </summary>
/// <remarks>
/// POST, mert ír. A szolgáltató kvótája szűk (teljes lekérdezésből 30 naponta 10, id nélkül óránként 20),
/// é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(bool full = false)
{
if (!await permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
try
{
var result = await syncService.SyncAsync(full);
return Json(new
{
success = true,
wasFullLoad = result.WasFullLoad,
fetched = result.FetchedCount,
inserted = result.InsertedCount,
updated = result.UpdatedCount,
skipped = result.SkippedCount,
failed = result.FailedCount
});
}
catch (Exception ex)
{
// A hiba szövege az adminnak szól: a hiányzó beállítás és a szolgáltató hibája is ide fut be, és a
// kettő megkülönböztetése az üzenetből derül ki.
return Json(new { success = false, message = ex.Message });
}
}
}
@@ -0,0 +1,120 @@
using System.Globalization;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Security;
using Nop.Web.Areas.Admin.Controllers;
using Nop.Web.Framework;
using Nop.Web.Framework.Mvc.Filters;
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
/// <summary>Havi ÁFA-kimutatás: a tükrözött bejövő számlák és a saját eladásaink szembeállítva.</summary>
/// <remarks>Belső tájékozódásra — nem bevallás. A korlátait a <see cref="VatReportService"/> írja le.</remarks>
[Area(AreaNames.ADMIN)]
[AuthorizeAdmin]
public class VatReportController(
IPermissionService permissionService,
VatReportService vatReportService)
: BaseAdminController
{
[HttpGet]
public async Task<IActionResult> Index(DateTime? from, DateTime? to,
string? text, string? currency, bool onlyWithVat = false)
{
if (!await permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
var model = await BuildAsync(from, to, text, currency, onlyWithVat);
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/VatReport/Index.cshtml", model);
}
/// <summary>
/// A SZŰRT tételes lista CSV-ben — a könyvelőnek továbbadható alak.
/// </summary>
/// <remarks>
/// Ugyanazt az időszakot és szűrőt kapja, mint a nézet, és ugyanazon a szolgáltatáson megy át: az exportált
/// tartalom így nem tud eltérni attól, amit a képernyőn látott. Külön lekérdezés-út két igazságot adna.
/// <para>Pontosvessző elválasztó, tizedesvessző és UTF-8 BOM: így a magyar Excel kattintásra, helyesen
/// nyitja meg — vessző-elválasztóval és ponttal minden szám egyetlen szövegoszlopba esne.</para>
/// </remarks>
[HttpGet]
public async Task<IActionResult> Export(DateTime? from, DateTime? to,
string? text, string? currency, bool onlyWithVat = false)
{
if (!await permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
var model = await BuildAsync(from, to, text, currency, onlyWithVat);
var hu = CultureInfo.GetCultureInfo("hu-HU");
var csv = new StringBuilder();
csv.AppendLine(string.Join(';',
"Teljesites", "Kelte", "Szallito", "Bizonylat", "Kulcs", "Deviza",
"Netto", "AFA", "Brutto", "Arfolyam", "AFA_HUF", "Tetel", "Felretett", "Csatolmany"));
foreach (var invoice in model.Invoices)
{
csv.AppendLine(string.Join(';',
Csv(invoice.PerformanceDate?.ToString("yyyy.MM.dd.")),
Csv(invoice.InvoiceDate?.ToString("yyyy.MM.dd.")),
Csv(invoice.SupplierName),
Csv(invoice.DocumentNumber),
Csv(invoice.RateLabel),
Csv(invoice.CurrencyCode),
invoice.NetAmount.ToString("0.##", hu),
invoice.VatAmount.ToString("0.##", hu),
invoice.GrossAmount.ToString("0.##", hu),
invoice.ExchangeRate > 0 ? invoice.ExchangeRate.ToString("0.####", hu) : string.Empty,
invoice.ConvertedVatAmount != 0 ? invoice.ConvertedVatAmount.ToString("0.##", hu) : string.Empty,
invoice.ItemCount.ToString(hu),
invoice.IsSetAside ? "igen" : string.Empty,
// Csak a fájlnév. Az InnVoice csatolmány-URL-je bérlő-szintű hozzáférési tokent hordoz, egy
// kimentett és továbbküldött CSV pedig a hozzáférést is vinné. A bizonylat a kimutatás
// felületén nyílik meg, ott a proxy-végpont mögött marad a token.
Csv(invoice.AttachmentFileName)));
}
var fileName = $"afa-bejovo-{model.FromInclusive:yyyyMMdd}-{model.ToExclusive.AddDays(-1):yyyyMMdd}.csv";
// BOM-mal: enélkül az Excel a magyar ékezeteket elrontja.
return File(Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv.ToString())).ToArray(),
"text/csv; charset=utf-8", fileName);
}
/// <summary>Idézőjelezés, ha a mező elválasztót, idézőjelet vagy sortörést tartalmaz.</summary>
private static string Csv(string? value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value.Contains(';') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')
? '"' + value.Replace("\"", "\"\"") + '"'
: value;
}
/// <summary>Az időszak és a szűrő feloldása — a nézet és az export közös bemenete.</summary>
private async Task<VatReportModel> BuildAsync(DateTime? from, DateTime? to,
string? text, string? currency, bool onlyWithVat)
{
// Alapértelmezés: az AKTUÁLIS hónap. Az egész év egyszerre több ezer sort ad, és a napi kérdés úgyis
// az, hogy ebben a hónapban hol tartunk. Az intervallum zártnyílt, hogy a hónap-határra eső tétel se
// essen ki és se duplázódjon.
var today = DateTime.UtcNow.Date;
var requestedFrom = (from ?? new DateTime(today.Year, today.Month, 1)).Date;
var toExclusive = to?.Date ?? new DateTime(today.Year, today.Month, 1).AddMonths(1);
// Az adathatár alá nem megyünk: a kimenő oldal ennél korábbra nem létezik, és a nulla kimenő áfa
// hiányzó adatot jelentene, nem valós nullát. Csendben felhúzzuk, de a felület megmondja, hogy tette.
var floor = VatReportService.DataAvailableFrom;
var fromInclusive = requestedFrom < floor ? floor : requestedFrom;
if (toExclusive <= fromInclusive) toExclusive = fromInclusive.AddMonths(1);
var filter = new VatReportInvoiceFilter { Text = text, Currency = currency, OnlyWithVat = onlyWithVat };
var model = await vatReportService.CalculateAsync(fromInclusive, toExclusive, filter);
model.FromClamped = requestedFrom < floor;
return model;
}
}
@@ -46,6 +46,20 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ZaiModel")]
public string ZaiModel { get; set; } = "glm-ocr";
// ── InnVoice ──────────────────────────────────────────────────────────────────
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.InnVoiceCompanyName")]
public string InnVoiceCompanyName { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.InnVoiceUsername")]
public string InnVoiceUsername { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.InnVoicePassword")]
public string InnVoicePassword { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.InnVoiceBaseUrl")]
public string InnVoiceBaseUrl { get; set; } = "https://api.innvoice.hu";
}
}
@@ -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; }
}
@@ -112,6 +112,39 @@
<small class="form-text text-muted">GLM-OCR modell neve. Alapesetben: <code>glm-ocr</code></small>
</div>
<hr class="my-4" />
<h5 class="mb-3"><i class="fas fa-file-invoice me-2"></i>InnVoice — Számlázó rendszer</h5>
<p class="text-muted small mb-3">
Az InnVoice API hozzáférése: a rendelés-beküldéshez és a bejövő számlák lekérdezéséhez.
Üresen hagyva az InnVoice hívások beszédes hibával leállnak — nem csendben.
</p>
<div class="form-group">
<label asp-for="InnVoiceCompanyName"></label>
<input asp-for="InnVoiceCompanyName" class="form-control" placeholder="fruitbank" />
<span asp-validation-for="InnVoiceCompanyName" class="text-danger"></span>
<small class="form-text text-muted">Regisztrációs név — ez az URL <code>%regnev%</code> szegmense.</small>
</div>
<div class="form-group">
<label asp-for="InnVoiceUsername"></label>
<input asp-for="InnVoiceUsername" class="form-control" placeholder="API felhasználónév" />
<span asp-validation-for="InnVoiceUsername" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="InnVoicePassword"></label>
<input asp-for="InnVoicePassword" class="form-control" type="password" placeholder="API jelszó" />
<span asp-validation-for="InnVoicePassword" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="InnVoiceBaseUrl"></label>
<input asp-for="InnVoiceBaseUrl" class="form-control" placeholder="https://api.innvoice.hu" />
<span asp-validation-for="InnVoiceBaseUrl" class="text-danger"></span>
<small class="form-text text-muted">Üresen hagyva: <code>https://api.innvoice.hu</code></small>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Mentés
@@ -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>
@@ -0,0 +1,609 @@
@using Nop.Web.Framework.Infrastructure
@model Nop.Plugin.Misc.FruitBankPlugin.Services.VatReportModel
@{
ViewBag.PageTitle = "ÁFA-kimutatás";
NopHtml.SetActiveMenuItemSystemName("FruitBank");
string Huf(decimal value) => value.ToString("N0") + " Ft";
string Money(decimal value, string currency) => currency is "Ft" or "HUF"
? value.ToString("N0") + " Ft"
: value.ToString("N2") + " " + currency;
string MonthName(int year, int month) => $"{year}. {month:00}.";
string Day(DateTime? date) => date?.ToString("yyyy.MM.dd.") ?? "—";
}
<div class="content-header clearfix">
<h1 class="float-left">Havi ÁFA-kimutatás</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">
<strong>Bejövő:</strong> az InnVoice-ból tükrözött szállítói számlák, a <strong>teljesítés kelte</strong>
szerinti hónapban. <strong>Kimenő:</strong> a saját lezárt rendeléseink áfája, a lezárás hónapjában.
</p>
<p class="mb-1 text-muted">
⚠️ <strong>Belső tájékozódásra</strong>, nem bevallás: nincs benne fordított adózás, arányosítás,
le nem vonható tétel. A szállítmány-költségek és a beszállítói szállítólevelek szándékosan nem
számítanak bele — azok is az InnVoice-on át jönnek, és kétszer venni őket duplázás lenne.
</p>
@* A lap NEM szinkronizál magától (a szolgáltató kvótája szűk), ezért a frissesség nem magától
értetődő — kiírva legalább látszik, elhallgatva a felhasználó régi adatot hinne mainak. *@
<p class="mb-0 text-muted">
<i class="fas fa-clock"></i>
A tükör utoljára frissítve:
<strong>@(Model.MirrorSyncedAt?.ToString("yyyy.MM.dd. HH:mm") ?? "még soha")</strong>
— a lap <strong>nem</strong> tölt le magától új számlákat.
<a href="@Url.Action("Index", "IncomingInvoice", new { area = "Admin" })">Szinkronizálás →</a>
</p>
</div>
</div>
@* ── Időszak ────────────────────────────────────────────────── *@
<div class="card card-default card-search mb-2">
<div class="card-body">
<form method="get" class="form-inline">
<label class="mr-2">Ettől:</label>
<input type="date" name="from" value="@Model.FromInclusive.ToString("yyyy-MM-dd")"
min="@Model.DataAvailableFrom.ToString("yyyy-MM-dd")" class="form-control mr-3" />
<label class="mr-2">Eddig:</label>
<input type="date" name="to" value="@Model.ToExclusive.ToString("yyyy-MM-dd")"
min="@Model.DataAvailableFrom.ToString("yyyy-MM-dd")" class="form-control mr-3" />
<label class="mr-2">Szállító / bizonylat:</label>
<input type="text" name="text" value="@Model.Filter.Text" placeholder="névrészlet vagy szám"
class="form-control mr-3" style="min-width:220px;" />
<label class="mr-2">Deviza:</label>
<select name="currency" class="form-control mr-3">
<option value="" selected="@(string.IsNullOrEmpty(Model.Filter.Currency))">mind</option>
<option value="huf" selected="@(Model.Filter.Currency == "huf")">csak forintos</option>
<option value="foreign" selected="@(Model.Filter.Currency == "foreign")">csak devizás</option>
</select>
<div class="form-check mr-3">
<input class="form-check-input" type="checkbox" name="onlyWithVat" value="true"
id="onlyWithVat" checked="@Model.Filter.OnlyWithVat" />
<label class="form-check-label" for="onlyWithVat">csak áfás</label>
</div>
<button type="submit" class="btn btn-primary"><i class="fas fa-search"></i> Lekérdezés</button>
@if (Model.Filter.IsActive)
{
<a href="@Url.Action("Index", "VatReport", new { area = "Admin", from = Model.FromInclusive.ToString("yyyy-MM-dd"), to = Model.ToExclusive.ToString("yyyy-MM-dd") })"
class="btn btn-light ml-2">Szűrő törlése</a>
}
</form>
<p class="mb-0 mt-2 text-muted small">
A kimutatás <strong>@Model.DataAvailableFrom.ToString("yyyy.MM.dd.")</strong> előttre nem kérdezhető le.
A bejövő számlák ugyan korábbra is megvannak a tükörben, de az <strong>eladási oldal</strong> csak
ettől a naptól áll össze — korábbra a nulla kimenő áfa hiányzó adatot jelentene, nem valós nullát.
</p>
</div>
</div>
@* ── Figyelmeztetések ───────────────────────────────────────── *@
@if (Model.FromClamped)
{
<div class="alert alert-info">
A kért kezdő dátum korábbi volt az adathatárnál, ezért
<strong>@Model.DataAvailableFrom.ToString("yyyy.MM.dd.")</strong>-tól számoltunk.
</div>
}
@if (Model.WithoutPerformanceDate > 0 || Model.ImplausiblePerformanceDate > 0)
{
<div class="alert alert-warning">
<strong>Egyik hónapba sem esik bele</strong> (a teljes tükörben, nem csak ebben az időszakban):
@if (Model.WithoutPerformanceDate > 0)
{
<text>@Model.WithoutPerformanceDate számla teljesítés-dátum nélkül. </text>
}
@if (Model.ImplausiblePerformanceDate > 0)
{
<text>@Model.ImplausiblePerformanceDate számla értelmetlen teljesítés-dátummal (elgépelt évszám). </text>
}
Ezek a sorok <strong>egyetlen hónap összegében sem szerepelnek</strong> — az InnVoice-ban kell javítani a dátumot,
utána egy szinkron átveszi.
</div>
}
@if (Model.Rows.Count == 0)
{
<div class="alert alert-info">Ebben az időszakban nincs adat.</div>
}
else
{
@* ── A LÉNYEG egy helyen ────────────────────────────────────
A napi kérdés egyetlen szám: mennyi az áfa. A devizánkénti sorok külön állnak, mert átváltás
nélkül nem adhatók össze — de ITT vannak, nem három panellel lejjebb. *@
<div class="card card-primary card-outline mb-3">
<div class="card-header"><strong>ÁFA — @Model.FromInclusive.ToString("yyyy.MM.dd.") @Model.ToExclusive.AddDays(-1).ToString("yyyy.MM.dd.")</strong></div>
<div class="card-body">
<div class="row text-center">
<div class="col-md-4 border-right">
<div class="text-muted text-uppercase small">Kimenő ÁFA (HUF)</div>
<div style="font-size:1.9rem; font-weight:700;">@Huf(Model.TotalOutgoingVat)</div>
</div>
<div class="col-md-4 border-right">
<div class="text-muted text-uppercase small">Bejövő ÁFA (HUF)</div>
<div style="font-size:1.9rem; font-weight:700;">@Huf(Model.TotalIncomingVatHuf)</div>
@if (Model.TotalConvertedVat != 0)
{
<div class="small text-muted">
forintos: @Huf(Model.TotalIncomingVat) · devizásból átváltva: @Huf(Model.TotalConvertedVat)
</div>
}
</div>
<div class="col-md-4">
<div class="text-muted text-uppercase small">Egyenleg (HUF)</div>
<div style="font-size:1.9rem; font-weight:700;" class="@(Model.TotalBalance >= 0 ? "text-danger" : "text-success")">
@Huf(Model.TotalBalance)
</div>
<div class="small text-muted">@(Model.TotalBalance >= 0 ? "fizetendő" : "visszaigényelhető")</div>
</div>
</div>
@{ var foreignTotals = Model.CurrencyTotals.Where(c => !c.IsHuf).ToList(); }
@if (foreignTotals.Any())
{
<hr />
<div class="row text-center">
@foreach (var currency in foreignTotals)
{
<div class="col-md-4">
<div class="text-muted text-uppercase small">Bejövő ÁFA (@currency.CurrencyCode)</div>
<div style="font-size:1.5rem; font-weight:700;" class="@(currency.VatAmount != 0 ? "text-danger" : "")">
@Money(currency.VatAmount, currency.CurrencyCode)
</div>
<div class="small text-muted">
@currency.InvoiceCount számla · nettó @Money(currency.NetAmount, currency.CurrencyCode)
@if (currency.ConvertedVatAmount != 0)
{
<br />
<text>ebből forintosítva: @Huf(currency.ConvertedVatAmount)</text>
}
</div>
</div>
}
</div>
@if (Model.NeedsExchangeRate.Any())
{
<div class="alert alert-danger mt-3 mb-0 py-2">
<strong>@Model.NeedsExchangeRate.Count devizás számlán van áfa, de nincs árfolyam.</strong>
Ezek áfája <strong>hiányzik</strong> a fenti forintos összegből — a lista lent, az
„Ezekre kell árfolyam" panelben. Az InnVoice-ban az <em>Árfolyam (Ft)</em> mezőt kell
kitölteni (a <em>teljesítés napja</em> gombbal), utána egy szinkron átveszi.
</div>
}
}
</div>
</div>
@* ── Havi összesítő ─────────────────────────────────────── *@
<div class="card card-default mb-2">
<div class="card-header">
<strong>Havi összesítő</strong> <span class="text-muted small">— forintos számlákból</span>
<div class="card-tools float-right">
<button type="button" class="btn btn-tool" data-card-widget="collapse"><i class="fas fa-minus"></i></button>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Hónap</th>
<th class="text-right">Kimenő nettó</th>
<th class="text-right">Kimenő ÁFA</th>
<th class="text-right">Bejövő nettó</th>
<th class="text-right">Bejövő ÁFA</th>
<th class="text-right">Egyenleg</th>
<th>Megjegyzés</th>
</tr>
</thead>
<tbody>
@foreach (var row in Model.Rows)
{
var foreign = row.CurrencyRows.Where(c => !c.IsHuf).ToList();
var monthStart = new DateTime(row.Year, row.Month, 1);
<tr>
<td>
@* A hónapra szűkítés egy kattintás; a szűrő megmarad, hogy ne kelljen újra beírni. *@
<a href="@Url.Action("Index", "VatReport", new { area = "Admin", from = monthStart.ToString("yyyy-MM-dd"), to = monthStart.AddMonths(1).ToString("yyyy-MM-dd"), text = Model.Filter.Text, currency = Model.Filter.Currency, onlyWithVat = Model.Filter.OnlyWithVat })"
title="Szűkítés erre a hónapra">
<strong>@MonthName(row.Year, row.Month)</strong>
</a>
</td>
<td class="text-right">@Huf(row.OutgoingNetAmount)</td>
<td class="text-right">@Huf(row.OutgoingVatAmount)</td>
<td class="text-right">@Huf(row.IncomingNetAmount)</td>
<td class="text-right">
@Huf(row.IncomingVatHuf)
@if (row.ConvertedVatAmount != 0)
{
<br />
<span class="small text-muted">(ebből devizás: @Huf(row.ConvertedVatAmount))</span>
}
</td>
<td class="text-right">
<strong class="@(row.Balance >= 0 ? "text-danger" : "text-success")">@Huf(row.Balance)</strong>
</td>
<td class="small text-muted">
@row.IncomingInvoiceCount forintos bejövő számla · @row.OutgoingOrderItemCount eladott tétel
@foreach (var currency in foreign)
{
<br />
<span class="text-info">
+ @currency.InvoiceCount db @currency.CurrencyCode számla:
nettó @Money(currency.NetAmount, currency.CurrencyCode),
ÁFA @Money(currency.VatAmount, currency.CurrencyCode)
— a fenti forintos oszlopokban nem szerepel
</span>
}
@if (row.SetAsideCount > 0)
{
<br /><span>@row.SetAsideCount félretett számla (benne van az összegben)</span>
}
@if (row.MissingOrderItemCount > 0)
{
<br /><span class="text-danger">@row.MissingOrderItemCount eladott tétel nem található — a kimenő ÁFA ennyivel kevesebb</span>
}
@* A kimenő oldal a fogyasztás-sorokra épül, azok pedig csak az árrés-funkció
élesítése óta keletkeznek. Egy korábbi hónapban a bejövő valós, a kimenő
nulla — az egyenleg ilyenkor nem hiányzó áfa, hanem hiányzó ADAT. *@
@if (row.IncomingInvoiceCount > 0 && row.OutgoingOrderItemCount == 0)
{
<br /><span class="text-danger"><strong>Nincs eladási adat ebben a hónapban</strong> — az egyenleg félrevezető</span>
}
</td>
</tr>
}
</tbody>
<tfoot class="font-weight-bold">
<tr>
<td>Összesen</td>
<td></td>
<td class="text-right">@Huf(Model.TotalOutgoingVat)</td>
<td></td>
<td class="text-right">@Huf(Model.TotalIncomingVatHuf)</td>
<td class="text-right">@Huf(Model.TotalBalance)</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<div class="card-footer py-2 small text-muted">
Az <strong>egyenleg</strong> = kimenő ÁFA bejövő ÁFA. Pozitív érték fizetendőt, negatív
visszaigényelhetőt jelez — a fenti korlátokkal.
</div>
</div>
@* ── Devizánkénti bontás ────────────────────────────────── *@
<div class="card card-default mb-2">
<div class="card-header">
<strong>Bejövő számlák devizánként</strong>
<div class="card-tools float-right">
<button type="button" class="btn btn-tool" data-card-widget="collapse"><i class="fas fa-minus"></i></button>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Deviza</th>
<th class="text-right">Számla</th>
<th class="text-right">Nettó</th>
<th class="text-right">ÁFA</th>
<th class="text-right">Forintosítva</th>
<th>Beleszámít a HUF-egyenlegbe?</th>
</tr>
</thead>
<tbody>
@foreach (var currency in Model.CurrencyTotals)
{
<tr>
<td><strong>@currency.CurrencyCode</strong></td>
<td class="text-right">@currency.InvoiceCount</td>
<td class="text-right">@Money(currency.NetAmount, currency.CurrencyCode)</td>
<td class="text-right">@Money(currency.VatAmount, currency.CurrencyCode)</td>
<td class="text-right">@(currency.IsHuf ? "—" : Huf(currency.ConvertedVatAmount))</td>
<td>
@if (currency.IsHuf)
{
<span class="text-success">igen</span>
}
else if (currency.MissingRateCount == 0)
{
<span class="text-success">igen — a számlákon rögzített árfolyammal</span>
}
else
{
<span class="text-danger">
részben — <strong>@currency.MissingRateCount</strong> áfás számlán nincs árfolyam,
azok hiányoznak az összegből
</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="card-footer py-2 small text-muted">
A devizás áfát a <strong>számlán rögzített árfolyammal</strong> forintosítjuk — nem piaci
kurzussal. Egy euróban kiállított, magyar áfás számlán az áfa forintértéke a bizonylaton
szerepel, és az a jogilag érvényes szám. Ahol nincs árfolyam, ott <strong>nem tippelünk</strong>:
a sor kimarad a forintos összegből, és bekerül az „Ezekre kell árfolyam" listába. A nettó
oszlopot szándékosan nem váltjuk át — az egyenleghez nem kell, és hamis pontosságot sugallna.
</div>
</div>
@* ── Áfakulcsonkénti bontás ─────────────────────────────── *@
<div class="card card-default collapsed-card mb-2">
<div class="card-header">
<strong>Bejövő ÁFA kulcsonként</strong> <span class="text-muted small">— forintos számlákból</span>
<div class="card-tools float-right">
<button type="button" class="btn btn-tool" data-card-widget="collapse"><i class="fas fa-plus"></i></button>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Hónap</th>
<th>Kulcs</th>
<th class="text-right">Számla</th>
<th class="text-right">Nettó</th>
<th class="text-right">ÁFA</th>
</tr>
</thead>
<tbody>
@foreach (var row in Model.Rows)
{
foreach (var rate in row.RateRows.OrderBy(r => r.Bucket).ThenBy(r => r.VatRate))
{
<tr>
<td>@MonthName(row.Year, row.Month)</td>
<td>@rate.Label</td>
<td class="text-right">@rate.InvoiceCount</td>
<td class="text-right">@Huf(rate.NetAmount)</td>
<td class="text-right">@Huf(rate.VatAmount)</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="card-footer py-2 small text-muted">
A kulcsot a számla <strong>tételei</strong> adják, az összeget viszont a <strong>fej</strong> —
a tételekből visszaszámolt érték (bruttó egységár, kedvezmény, kerekítés) nem egyezne vele.
Ezért az egykulcsos számla teljes összege az adott kulcshoz kerül, a több kulcsot tartalmazó
pedig a <em>vegyes kulcs</em> sorra. A sorok összege így pontosan a havi összeg.
</div>
</div>
@* ── Ezekre kell árfolyam — az admin munkalapja ─────────── *@
@if (Model.NeedsExchangeRate.Any())
{
<div class="card card-danger card-outline mb-2">
<div class="card-header">
<strong>Ezekre kell árfolyam</strong>
<span class="text-muted small">— @Model.NeedsExchangeRate.Count számla</span>
<div class="card-tools float-right">
<button type="button" class="btn btn-tool" data-card-widget="collapse"><i class="fas fa-minus"></i></button>
</div>
</div>
<div class="card-body py-2">
<p class="mb-0 text-muted small">
Devizás számla, amin <strong>van áfa</strong>, de nincs árfolyam — ezek áfája hiányzik a
forintos összegből. Az InnVoice-ban a számla <em>Árfolyam (Ft)</em> mezőjét kell kitölteni,
a <em>teljesítés napja</em> gombbal (nem a <em>ma</em> gombbal: az áfa a teljesítéshez
igazodik). Utána egy <strong>Szinkronizálás</strong> átveszi, és a szám magától helyre áll.
</p>
</div>
<div class="card-body p-0" style="max-height:400px; overflow-y:auto;">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Teljesítés</th>
<th>Szállító</th>
<th>Bizonylat</th>
<th>Kulcs</th>
<th class="text-right">Nettó</th>
<th class="text-right">Hiányzó ÁFA</th>
</tr>
</thead>
<tbody>
@foreach (var invoice in Model.NeedsExchangeRate)
{
<tr>
<td>@Day(invoice.PerformanceDate)</td>
<td>@invoice.SupplierName</td>
<td>@invoice.DocumentNumber</td>
<td>@invoice.RateLabel</td>
<td class="text-right">@Money(invoice.NetAmount, invoice.CurrencyCode)</td>
<td class="text-right text-danger"><strong>@Money(invoice.VatAmount, invoice.CurrencyCode)</strong></td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@* ── Tételes lista ──────────────────────────────────────── *@
<div class="card card-default mb-2">
<div class="card-header">
<strong>A számlák tételesen</strong>
@if (Model.Filter.IsActive)
{
<span class="text-muted small">— @Model.InvoiceMatchCount / @Model.InvoiceTotalCount sor (szűrve)</span>
}
else
{
<span class="text-muted small">— @Model.InvoiceTotalCount sor</span>
}
<a href="@Url.Action("Export", "VatReport", new { area = "Admin", from = Model.FromInclusive.ToString("yyyy-MM-dd"), to = Model.ToExclusive.ToString("yyyy-MM-dd"), text = Model.Filter.Text, currency = Model.Filter.Currency, onlyWithVat = Model.Filter.OnlyWithVat })"
class="btn btn-sm btn-light ml-2" title="A szűrt lista letöltése CSV-ben (magyar Excel-barát)">
<i class="fas fa-file-csv"></i> Export
</a>
<div class="card-tools float-right">
<button type="button" class="btn btn-tool" data-card-widget="collapse"><i class="fas fa-minus"></i></button>
</div>
</div>
@if (Model.InvoiceListTruncated)
{
<div class="card-body py-2">
<div class="alert alert-warning mb-0 py-2">
A lista le van vágva. Szűkítsd az időszakot, ha az összes számlát látni akarod.
</div>
</div>
}
<div class="card-body p-0" style="max-height:600px; overflow-y:auto;">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Teljesítés</th>
<th>Kelte</th>
<th>Szállító</th>
<th>Bizonylat</th>
<th>Kulcs</th>
<th class="text-right">Nettó</th>
<th class="text-right">ÁFA</th>
<th class="text-right">Bruttó</th>
<th class="text-right">Árfolyam</th>
<th class="text-right">Tétel</th>
<th class="text-center">Kép</th>
</tr>
</thead>
<tbody>
@foreach (var invoice in Model.Invoices)
{
<tr class="@(invoice.IsHuf ? "" : "text-muted")">
<td>@Day(invoice.PerformanceDate)</td>
<td>@Day(invoice.InvoiceDate)</td>
<td>
@invoice.SupplierName
@if (invoice.IsSetAside)
{
<span class="badge badge-warning ml-1">félretett</span>
}
</td>
<td>@invoice.DocumentNumber</td>
<td>@invoice.RateLabel</td>
<td class="text-right">@Money(invoice.NetAmount, invoice.CurrencyCode)</td>
<td class="text-right">@Money(invoice.VatAmount, invoice.CurrencyCode)</td>
<td class="text-right">@Money(invoice.GrossAmount, invoice.CurrencyCode)</td>
<td class="text-right">
@if (invoice.IsHuf)
{
<span class="text-muted">—</span>
}
else if (invoice.ExchangeRate > 0)
{
<text>@invoice.ExchangeRate.ToString("N2")</text>
}
else if (invoice.NeedsExchangeRate)
{
<span class="text-danger" title="Van rajta áfa, de nincs árfolyam — hiányzik a forintos összegből">hiányzik</span>
}
else
{
<span class="text-muted" title="Nincs árfolyam, de az áfa nulla — a fizetendőn nem változtat">—</span>
}
</td>
<td class="text-right">@invoice.ItemCount</td>
<td class="text-center">
@if (!string.IsNullOrEmpty(invoice.AttachmentUrl))
{
<a href="#" class="js-attachment"
data-url="@Url.Action("Attachment", "IncomingInvoice", new { area = "Admin", id = invoice.Id })"
data-title="@invoice.SupplierName — @invoice.DocumentNumber"
title="@(invoice.AttachmentCount > 1 ? $"{invoice.AttachmentFileName} (a számlán {invoice.AttachmentCount} csatolmány van, az elsőt mutatjuk)" : invoice.AttachmentFileName)">
<i class="fas fa-paperclip"></i>
@if (invoice.AttachmentCount > 1)
{
<sup>@invoice.AttachmentCount</sup>
}
</a>
}
</td>
</tr>
}
</tbody>
<tfoot class="font-weight-bold">
@foreach (var group in Model.Invoices.GroupBy(invoice => invoice.CurrencyCode))
{
<tr>
<td colspan="5">Látható sorok összesen (@group.Key)</td>
<td class="text-right">@Money(group.Sum(x => x.NetAmount), group.Key)</td>
<td class="text-right">@Money(group.Sum(x => x.VatAmount), group.Key)</td>
<td class="text-right">@Money(group.Sum(x => x.GrossAmount), group.Key)</td>
<td colspan="3"></td>
</tr>
}
</tfoot>
</table>
</div>
<div class="card-footer py-2 small text-muted">
<strong>A szűrő csak ezt a listát érinti</strong> — a fenti havi összesítők, a devizánkénti
és a kulcsonkénti bontás mindig a teljes időszakot mutatják. Egy szállítóra szűrt havi
ÁFA-összeg részigazságot mondana, miközben teljesnek látszana.
<br />
A <span class="text-muted">halványan</span> szedett sorok devizás számlák — a forintos
összesítőkben nem szerepelnek. A <i class="fas fa-paperclip"></i> a beszkennelt bizonylatot
nyitja meg <strong>helyben, felugró ablakban</strong>; ha a számlán több csatolmány van, a szám
jelzi, és <strong>csak az elsőt</strong> érjük el innen. A <strong>Kulcs</strong> oszlop a számla tételeiből jön; nulla
kulcsnál a jogcím szövege (AAM, ÁTK) áll ott, mert azt a szám nem őrzi meg.
</div>
</div>
}
</div>
</section>
@* ── Bizonylat-néző ─────────────────────────────────────────────────────────
A tartalom a saját kiszolgálónkról jön (Attachment proxy), ezért beágyazható, és nem letöltésként
érkezik. Az iframe forrását bezáráskor ürítjük, különben a PDF a háttérben betöltve maradna. *@
<div class="modal fade" id="attachmentModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content" style="height:90vh;">
<div class="modal-header">
<h5 class="modal-title" id="attachmentTitle"></h5>
<a href="#" id="attachmentNewTab" target="_blank" rel="noopener" class="btn btn-sm btn-light ml-auto mr-2">
<i class="fas fa-external-link-alt"></i> Új lapon
</a>
<button type="button" class="close" data-dismiss="modal" aria-label="Bezár">
<span aria-hidden="true">&times;</span>
</button>
</div>
@* A flex kell: a modal-body magassága a flex-elrendezésből jön, és egy százalékos magasságú
iframe egy ilyen szülőben nulla magas marad. *@
<div class="modal-body p-0" style="display:flex; overflow:hidden;">
<iframe id="attachmentFrame" src="" style="flex:1 1 auto; border:0;"></iframe>
</div>
</div>
</div>
</div>
<script>
$(function () {
$(document).on('click', '.js-attachment', function (e) {
e.preventDefault();
var url = $(this).data('url');
$('#attachmentTitle').text($(this).data('title') || 'Bizonylat');
$('#attachmentNewTab').attr('href', url);
$('#attachmentFrame').attr('src', url);
$('#attachmentModal').modal('show');
});
$('#attachmentModal').on('hidden.bs.modal', function () {
$('#attachmentFrame').attr('src', '');
});
});
</script>
@@ -960,20 +960,87 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
PartialModuleEnabled = FruitBankConstClient.PartialShippingEnabled
};
// A dátumtól induló kamionok — a LEZÁRTAK IS (I-Z9P6): különben a lezárás után eltűnnének, és nem lenne
// honnan újranyitni őket. A kliens jelöli/tompítja őket, és nem pakol rájuk.
var shippings = await ctx.Shippings.GetAll(false)
.Where(s => s.ShippingDate >= fromDate)
.ToListAsync();
// ── A megjelenítendő koherens halmaz ───────────────────────────────────
// A kamionnak és a szállítólevélnek KÜLÖN dátuma van. Amíg a kettőt egymástól függetlenül szűrtük, a
// rakomány-építés némán dobott el sorokat: a határon belüli kamion ÜRESEN jelent meg, ha az áruja egy
// régebbi dokumentumról jött, és maradt allocation, aminek nem volt hol megjelennie. Ezért a halmazt
// három, egymásra épülő lépésben választjuk ki.
// A tételek a dátumtól, termékkel + dokumentummal + partnerrel (ország/nap) — az allocationökkel együtt.
var items = await ctx.ShippingItems.GetAll(true)
// 1) Felrakó: itt a DOKUMENTUM dátuma dönt.
var stagingItems = await ctx.ShippingItems.GetAll(true)
.Where(si => si.ShippingDocument.ShippingDate >= fromDate)
.LoadWith(si => si.Allocations)
.ToListAsync();
// 2) Kamionok: a határon belüliek, PLUSZ azok, amikre a fenti tételek ki vannak osztva. A LEZÁRTAK IS
// (I-Z9P6): különben a lezárás után eltűnnének, és nem lenne honnan újranyitni őket. A kliens
// tompítja a lezártakat és a határon kívülről behúzottakat, és nem pakol rájuk.
var referencedShippingIds = stagingItems
.SelectMany(si => si.Allocations ?? [])
.Select(a => a.ShippingId)
.Distinct()
.ToList();
var shippings = await ctx.Shippings.GetAll(false)
.Where(s => s.ShippingDate >= fromDate || referencedShippingIds.Contains(s.Id))
.ToListAsync();
var shippingIds = shippings.Select(s => s.Id).ToList();
// 3) Rakomány: a megjelenített kamionokra kiosztott MINDEN tétel, a dokumentum dátumától függetlenül.
// EGY lépés, nem tranzitív lezárás: a behúzott kamion rakománya teljes, de egy rajta lévő tétel
// harmadik, határon kívüli kamionját már nem húzzuk be — különben egy régi lánc az egész
// adatbázist beránthatná.
var stagingItemIds = stagingItems.Select(si => si.Id).ToHashSet();
var loadItems = await ctx.ShippingItems.GetAll(true)
.Where(si => si.Allocations!.Any(a => shippingIds.Contains(a.ShippingId)))
.LoadWith(si => si.Allocations)
.ToListAsync();
var items = stagingItems
.Concat(loadItems.Where(si => !stagingItemIds.Contains(si.Id)))
.ToList();
var itemById = items.ToDictionary(si => si.Id);
// A felrakodás országa a TELEPHELYRŐL jön, ezért a partnerek telephelyeit külön kérdezzük le: a
// ShippingDocument.PartnerDepot nav SZÁNDÉKOSAN nincs benne egyetlen alapértelmezett loadRelations-ben
// sem (csak az EKÁER kéri explicit LoadWith-tel), és ezt nem írjuk felül egy mély betöltési lánccal.
var partnerIds = items
.Select(si => si.ShippingDocument?.PartnerId ?? 0)
.Where(partnerId => partnerId > 0)
.Distinct()
.ToList();
var depotsByPartnerId = (await ctx.PartnerDepots.GetAll()
.Where(depot => partnerIds.Contains(depot.PartnerId))
.ToListAsync())
.GroupBy(depot => depot.PartnerId)
.ToDictionary(g => g.Key, g => g.ToList());
var depotById = depotsByPartnerId.Values
.SelectMany(depots => depots)
.ToDictionary(depot => depot.Id);
// A felrakodás országa: a dokumentumhoz kapcsolt telephely dönt, mert a kamion oda megy. Telephely
// híján a partner EGYETLEN telephelye még használható; többnél viszont tippelni tilos — pont az a
// hibalehetőség, amit ez megszüntet (egy lengyel cégnek lehet holland felrakodó telephelye).
(string? Code, PlannerCountrySource Source) ResolveCountry(ShippingDocument? document)
{
if (document == null) return (null, PlannerCountrySource.Partner);
if (document.PartnerDepotId is > 0
&& depotById.TryGetValue(document.PartnerDepotId.Value, out var linkedDepot))
return (linkedDepot.CountryCode, PlannerCountrySource.Depot);
if (depotsByPartnerId.TryGetValue(document.PartnerId, out var partnerDepots)
&& partnerDepots.Count == 1)
return (partnerDepots[0].CountryCode, PlannerCountrySource.SinglePartnerDepot);
return (document.Partner?.CountryCode, PlannerCountrySource.Partner);
}
// ── Kamionok + rakomány ────────────────────────────────────────────────
var allocationsByShipping = items
.SelectMany(si => si.Allocations ?? [])
@@ -1005,7 +1072,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
ShippingItemId = item.Id,
ShippingId = shipping.Id,
ProductName = item.ProductName,
CountryCode = item.ShippingDocument?.Partner?.CountryCode,
CountryCode = ResolveCountry(item.ShippingDocument).Code,
// Ezt a tételt erről a kamionról már lemérték → ide több nem osztható (W2J6).
IsMeasuredHere = (item.ShippingItemPallets ?? [])
.Any(p => p.ShippingItemToShippingId == allocation.Id && p.IsMeasured),
@@ -1018,7 +1085,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
});
}
// Egy kamionra csak azonos ország pakolható → az elsőé a mérvadó.
// A kamion VEZETŐ országa az első soré. Nem az egyetlen: vegyes rakomány kézzel, a felület
// jóváhagyásával létrejöhet — a szerver nem vizsgál országot. Az FFD viszont nem kever.
truck.CountryCode = truck.Load.FirstOrDefault()?.CountryCode;
}
@@ -1035,12 +1103,19 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
if (remainingQuantity <= 0) continue; // teljesen kiosztva
// Csak a felrakói halmaz kerül ki a felrakóra: a 3) lépésben behúzott, régebbi dokumentumú tétel a
// kamionján látszik, de a felrakón nincs keresnivalója — az a dátumhatáron kívül esik.
if (!stagingItemIds.Contains(item.Id)) continue;
var (countryCode, countrySource) = ResolveCountry(item.ShippingDocument);
model.Items.Add(new PlannerItem
{
ShippingItemId = item.Id,
ProductName = item.ProductName,
ProductId = item.ProductId,
CountryCode = item.ShippingDocument?.Partner?.CountryCode,
CountryCode = countryCode,
CountrySource = countrySource,
PartnerName = item.ShippingDocument?.Partner?.Name,
ShippingDate = item.ShippingDocument?.ShippingDate ?? default,
RemainingQuantity = remainingQuantity,
@@ -1078,14 +1153,20 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
var result = new ShipmentPlanResult { Applied = apply };
var capacityKg = model.CapacityKg;
int Capacity(PlannerTruck truck) =>
model.PartialModuleEnabled && truck.IsPartial && truck.AvailablePallets.HasValue
? Math.Max(0, truck.AvailablePallets.Value)
: model.CapacityPallets;
// A szabály a helperben lakik, itt nem másoljuk: gyűjtőjáratnál a hiányzó raklapszám 0, nem a teljes
// szerelvény. A `model.PartialModuleEnabled` ugyanabból a konstansból jött pár sorral feljebb, tehát a
// helper kapuja és a modellé itt ugyanaz az érték.
int Capacity(PlannerTruck truck)
=> ShippingCapacityHelper.PalletCapacity(truck.IsPartial, truck.AvailablePallets);
// Futó terhelés kamiononként (a meglévő rakomány a kiindulás), és a kamion aktuális országa (üresnél null).
// A LEZÁRT kamionokra nem pakolunk (a modell azért tartalmazza őket, hogy a tervezőből újranyithatók legyenek).
var openTrucks = model.Trucks.Where(t => !t.IsClosed).ToList();
// A dátumhatáron KÍVÜLRŐL behúzott kamionokra sem: azok azért vannak a modellben, hogy a rájuk osztott
// áru ne tűnjön el a képből — de az admin a mostani kamionokat tervezi, nem a régieket.
var openTrucks = model.Trucks
.Where(t => !t.IsClosed && t.ShippingDate >= fromDate)
.ToList();
// A raklap-oldal NEM additív (ADR 0004-M9F3): hogy egy tétel felrakása 0 vagy 1 extra raklaphelyet
// jelent-e, attól függ, van-e már fent csoporttársa. Ezért nem futó összeget viszünk, hanem a rakomány
@@ -47,7 +47,9 @@ public class FruitBankDbContext : MgDbContextBase,
ICustomerCreditDbSet<CustomerCreditDbTable>,
IShippingCostDbSet<ShippingCostDbTable>,
ICostConsumptionDbSet<CostConsumptionDbTable>,
IPreOrderFulfilmentDbSet<PreOrderFulfilmentDbTable>
IPreOrderFulfilmentDbSet<PreOrderFulfilmentDbTable>,
IIncomingInvoiceDbSet<IncomingInvoiceDbTable>,
IIncomingInvoiceItemDbSet<IncomingInvoiceItemDbTable>
{
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly CostAttributionService _costAttributionService;
@@ -85,6 +87,12 @@ public class FruitBankDbContext : MgDbContextBase,
/// <summary>Előrendelés-teljesítések: melyik szállítólevél mennyit teljesített egy előrendelés-tételből.</summary>
public PreOrderFulfilmentDbTable PreOrderFulfilments { get; set; }
/// <summary>Az InnVoice-ból tükrözött bejövő számlák feje — a havi ÁFA-kimutatás bejövő oldala.</summary>
public IncomingInvoiceDbTable IncomingInvoices { get; set; }
/// <summary>A tükrözött bejövő számlák tételei — az ÁFA-kulcsonkénti bontás forrása.</summary>
public IncomingInvoiceItemDbTable IncomingInvoiceItems { get; set; }
public OrderItemPalletDbTable OrderItemPallets { get; set; }
public FilesDbTable Files { get; set; }
@@ -116,6 +124,7 @@ public class FruitBankDbContext : MgDbContextBase,
ExternalUserPartnerMapDbTable externalUserPartnerMapDbTable, ProductTextMappingDbTable productTextMappingDbTable,
ShippingCostDbTable shippingCostDbTable, CostConsumptionDbTable costConsumptionDbTable,
PreOrderFulfilmentDbTable preOrderFulfilmentDbTable,
IncomingInvoiceDbTable incomingInvoiceDbTable, IncomingInvoiceItemDbTable incomingInvoiceItemDbTable,
CostAttributionService costAttributionService,
IProductService productService, IStaticCacheManager staticCacheManager,
IRepository<Order> orderRepository,
@@ -180,6 +189,8 @@ public class FruitBankDbContext : MgDbContextBase,
ShippingCosts = shippingCostDbTable;
CostConsumptions = costConsumptionDbTable;
PreOrderFulfilments = preOrderFulfilmentDbTable;
IncomingInvoices = incomingInvoiceDbTable;
IncomingInvoiceItems = incomingInvoiceItemDbTable;
_costAttributionService = costAttributionService;
}
@@ -0,0 +1,63 @@
using LinqToDB;
using Mango.Nop.Data.Repositories;
using Nop.Core.Caching;
using Nop.Core.Configuration;
using Nop.Core.Events;
using Nop.Data;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
/// <summary>Az InnVoice-ból tükrözött bejövő számlák feje.</summary>
public class IncomingInvoiceDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings)
: MgDbTableBase<IncomingInvoice>(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
{
public override IQueryable<IncomingInvoice> GetAll() => base.GetAll();
public IQueryable<IncomingInvoice> GetAll(bool loadRelations)
=> loadRelations ? GetAll().LoadWith(invoice => invoice.Items) : GetAll();
/// <summary>Egy tükrözött számla az InnVoice azonosítója szerint — az upsert ezzel dönti el, van-e már sorunk.</summary>
public Task<IncomingInvoice> GetByInnVoiceTableIdAsync(int innVoiceTableId)
=> GetAll().FirstOrDefaultAsync(invoice => invoice.InnVoiceTableId == innVoiceTableId);
/// <summary>Több tükrözött számla egy menetben — a szinkron kerüli vele az N+1-et.</summary>
public IQueryable<IncomingInvoice> GetAllByInnVoiceTableIds(IReadOnlyCollection<int> innVoiceTableIds)
=> GetAll().Where(invoice => innVoiceTableIds.Contains(invoice.InnVoiceTableId));
/// <summary>
/// A TELJESÍTÉS kelte szerinti időszak számlái — a havi ÁFA-kimutatás bejövő oldala.
/// </summary>
/// <remarks>
/// A teljesítés dátuma dönt, nem a számla kelte és nem a kifizetés: az ÁFA is aszerint jár. A dátum
/// nullable, mert az InnVoice-ból üresen is érkezhet — az ilyen sor egyik hónapba sem esik bele, és a
/// kimutatásnak külön kell jeleznie, nem elnyelnie.
/// </remarks>
public IQueryable<IncomingInvoice> GetAllByPerformanceDate(DateTime fromInclusive, DateTime toExclusive)
=> GetAll().Where(invoice => invoice.PerformanceDate != null
&& invoice.PerformanceDate >= fromInclusive
&& invoice.PerformanceDate < toExclusive);
/// <summary>Teljesítés-dátum nélküli sorok — a kimutatás ezeket külön mutatja.</summary>
public IQueryable<IncomingInvoice> GetAllWithoutPerformanceDate()
=> GetAll().Where(invoice => invoice.PerformanceDate == null);
/// <summary>
/// A növekményes szinkron vízjele: a tárolt <see cref="IncomingInvoice.SourceUpdatedAt"/> maximuma.
/// </summary>
/// <remarks>
/// Nem tartunk külön vízjel-beállítást: a vízjel maga az adat. Üres táblánál <c>null</c> — a hívó ilyenkor
/// teljes lekérdezést indít. Így egy féloldalas szinkron sem tud „átugrani" számlákat: ami nem került be,
/// az a maximumot sem tolta el.
/// </remarks>
public Task<DateTime?> GetLatestSourceUpdatedAtAsync()
=> GetAll().MaxAsync(invoice => invoice.SourceUpdatedAt);
/// <summary>Mikor futott utoljára szinkron — a kimutatás ebből mondja meg, mennyire friss, amit mutat.</summary>
/// <remarks>
/// A tükör nem frissül magától a riport megnyitásakor (a szolgáltató kvótája szűk), ezért a frissesség
/// nem magától értetődő. Kiírva legalább látszik; elhallgatva a felhasználó régi adatot hinne mainak.
/// </remarks>
public Task<DateTime?> GetLastSyncedAtAsync()
=> GetAll().MaxAsync(invoice => (DateTime?)invoice.SyncedAt);
}
@@ -0,0 +1,23 @@
using LinqToDB;
using Mango.Nop.Data.Repositories;
using Nop.Core.Caching;
using Nop.Core.Configuration;
using Nop.Core.Events;
using Nop.Data;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
/// <summary>A tükrözött bejövő számlák tételei — az ÁFA-kulcsonkénti bontás forrása.</summary>
public class IncomingInvoiceItemDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings)
: MgDbTableBase<IncomingInvoiceItem>(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
{
public override IQueryable<IncomingInvoiceItem> GetAll() => base.GetAll();
public IQueryable<IncomingInvoiceItem> GetAllByIncomingInvoiceId(int incomingInvoiceId)
=> GetAll().Where(item => item.IncomingInvoiceId == incomingInvoiceId);
/// <summary>Több számla tételei egy menetben — a kimutatás kerüli vele az N+1-et.</summary>
public IQueryable<IncomingInvoiceItem> GetAllByIncomingInvoiceIds(IReadOnlyCollection<int> incomingInvoiceIds)
=> GetAll().Where(item => incomingInvoiceIds.Contains(item.IncomingInvoiceId));
}
@@ -0,0 +1,10 @@
using Mango.Nop.Data.Interfaces;
using Nop.Data;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
public interface IIncomingInvoiceDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<IncomingInvoice>
{
public TDbTable IncomingInvoices { get; set; }
}
@@ -0,0 +1,10 @@
using Mango.Nop.Data.Interfaces;
using Nop.Data;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
public interface IIncomingInvoiceItemDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<IncomingInvoiceItem>
{
public TDbTable IncomingInvoiceItems { get; set; }
}
@@ -0,0 +1,125 @@
using AyCode.Core.Serializers.Toons;
using AyCode.Interfaces.TimeStampInfo;
using LinqToDB.Mapping;
using Mango.Nop.Core.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
/// <summary>Az InnVoice-ból tükrözött bejövő (szállítói) számla feje.</summary>
/// <remarks>
/// Az admin az InnVoice-ba pakolja be a kifizetett számlákat; innen olvassuk vissza őket a havi
/// ÁFA-kimutatáshoz. A sorokat nem mi állítjuk elő, hanem MÁSOLJUK — ezért itt pénzügyi érték is tárolódik,
/// szemben az árrés-modellel (ADR 0002-R7Q2, 8. pont), ahol minden szám lekérdezés. Ott a szám a saját
/// adatunkból levezethető, itt viszont egy külső rendszeré: nincs mit kiszámolni, csak tükrözni.
///
/// Nincs <c>[AcBinarySerializable]</c>: ez a típus SOHA nem megy a wire-en — a költség-adat admin-only.
/// </remarks>
[ToonDescription("Supplier invoice mirrored from InnVoice",
Purpose = "Header of an incoming (supplier) invoice copied from the InnVoice accounting system, keyed by " +
"its InnVoice TABLE_ID. Feeds the monthly VAT overview; amounts are the accounting system's own " +
"figures, not derived here.")]
[Table(Name = FruitBankPluginConst.IncomingInvoiceDbTableName)]
[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankPluginConst.IncomingInvoiceDbTableName)]
public sealed class IncomingInvoice : MgEntityBase, ITimeStampCreated, ITimeStampModified
{
/// <summary>Az InnVoice <c>TABLE_ID</c>-ja — a tükrözés természetes kulcsa, erre megy az upsert.</summary>
[Column] public int InnVoiceTableId { get; set; }
/// <summary>Az InnVoice <c>_UniqueID</c> technikai azonosítója.</summary>
[Column] public string InnVoiceUniqueId { get; set; }
/// <summary>Szállító azonosítója az InnVoice törzsében; 0, ha nincs törzsadata.</summary>
[Column] public int SupplierId { get; set; }
[Column] public string SupplierName { get; set; }
/// <summary>Szállító országkódja — a belföldi/közösségi megkülönböztetéshez.</summary>
[Column] public string SupplierCountryCode { get; set; }
[Column] public string SupplierPostalCode { get; set; }
[Column] public string SupplierCity { get; set; }
[Column] public string SupplierStreet { get; set; }
/// <summary>Bizonylat száma (<c>Bizszam</c>).</summary>
[Column] public string DocumentNumber { get; set; }
/// <summary>Hivatkozási szám (<c>Hivszam</c>).</summary>
[Column] public string ReferenceNumber { get; set; }
/// <summary>Számla kelte.</summary>
[Column] public DateTime? InvoiceDate { get; set; }
/// <summary>Teljesítés kelte — EZ sorolja a számlát a havi ÁFA-kimutatás hónapjába.</summary>
[Column] public DateTime? PerformanceDate { get; set; }
/// <summary>Fizetési határidő.</summary>
[Column] public DateTime? DueDate { get; set; }
/// <summary>A számla devizaneme, ahogy az InnVoice adja (pl. "Ft", "EUR").</summary>
[Column] public string CurrencyCode { get; set; }
/// <summary>A számlán rögzített árfolyam (Ft/deviza); <b>0 = nem ismert</b>, nem 1.</summary>
/// <remarks>
/// Az InnVoice a bizonylaton tárolja (a felületén "Árfolyam (Ft)", a "teljesítés napja" gombbal töltve);
/// a válaszban <c>Arfolyam</c> néven jön, a dokumentációja nem említi. A forintos számlákon 0.
/// <para>A hiányt <b>soha nem 1-gyel</b> pótoljuk: az egy eurós összeget forintnak olvasna, és a hiba mindig
/// ugyanabba az irányba vinne. A riport helyette külön listán kéri be — de csak ahol az áfa nem nulla,
/// mert a 0%-os közösségi beszerzésnél az árfolyam a fizetendőn semmit nem mozdít.</para>
/// </remarks>
[Column] public decimal ExchangeRate { get; set; }
[Column] public decimal NetAmount { get; set; }
/// <summary>A számla ÁFA-tartalma — a havi kimutatás bejövő oldalának forrása.</summary>
[Column] public decimal VatAmount { get; set; }
[Column] public decimal GrossAmount { get; set; }
/// <summary>A számlára rögzített befizetések összege.</summary>
[Column] public decimal PaidAmount { get; set; }
/// <summary>Még nem kiegyenlített összeg.</summary>
[Column] public decimal OutstandingAmount { get; set; }
/// <summary>Fizetési mód, szabad szöveg.</summary>
[Column] public string PaymentMethod { get; set; }
/// <summary>A számla megjegyzése.</summary>
[Column] public string Note { get; set; }
/// <summary>Félretett (nem lezárt) számla. A kimutatás megjeleníti, de megjelöli.</summary>
[Column] public bool IsSetAside { get; set; }
/// <summary>A NAV online számla előnézeti linkje, ha az InnVoice adott ilyet.</summary>
[Column] public string NavLink { get; set; }
/// <summary>Az ELSŐ csatolmány fájlneve (a beszkennelt bizonylat).</summary>
[Column] public string AttachmentFileName { get; set; }
/// <summary>Az ELSŐ csatolmány letöltési URL-je.</summary>
/// <remarks>Bérlő-szintű hozzáférési tokent tartalmaz: admin-only felületen jelenik meg, nem adjuk tovább.</remarks>
[Column] public string AttachmentUrl { get; set; }
/// <summary>Hány csatolmánya van a számlának a forrásban.</summary>
/// <remarks>
/// Csak az elsőt tároljuk, mert a gyakorlatban egy PDF van számlánként. Ez a mező azért van, hogy a
/// veszteség LÁTHATÓ legyen: ha valahol 1-nél több áll, ott a felület nem mutat meg mindent.
/// </remarks>
[Column] public int AttachmentCount { get; set; }
/// <summary>Az InnVoice <c>_UpdateTime</c> értéke.</summary>
/// <remarks>
/// Ez a növekményes szinkron vízjele: a következő lekérdezés a tárolt MAXIMUMÁTÓL kér változásokat.
/// Külön vízjel-beállítást ezért nem tartunk — ami nincs a táblában, azt úgyis újra kell kérni.
/// </remarks>
[Column] public DateTime? SourceUpdatedAt { get; set; }
/// <summary>Mikor tükröztük utoljára ezt a sort.</summary>
[Column] public DateTime SyncedAt { get; set; }
[Association(ThisKey = nameof(Id), OtherKey = nameof(IncomingInvoiceItem.IncomingInvoiceId), CanBeNull = true)]
public List<IncomingInvoiceItem> Items { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
}
@@ -0,0 +1,61 @@
using AyCode.Core.Serializers.Toons;
using LinqToDB.Mapping;
using Mango.Nop.Core.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
/// <summary>Egy tétel a tükrözött bejövő számláról.</summary>
/// <remarks>
/// A tételek egyetlen dolgot szolgálnak: az ÁFA-kulcsonkénti bontást. Egy magyar számlán keveredhet a 27% és
/// az 5%/0%, amit a fej egyetlen <c>AFAErtek</c> mezője nem tud leképezni.
///
/// ⚠️ A tételekből visszaszámolt összeg NEM feltétlenül egyezik a fej összegével: az egységár lehet bruttó
/// (<see cref="IsGrossUnitPrice"/>), van rajta kedvezmény, és kerekítés is közbejön. Ezért a kimutatásban a
/// FEJ az igazság, a tételek csak a bontás — eltérésnél a felület jelez, nem hallgat.
/// </remarks>
[ToonDescription("One line of a mirrored supplier invoice",
Purpose = "Line item of an incoming invoice copied from InnVoice. Exists for the per-VAT-rate breakdown; " +
"the header totals stay authoritative.")]
[Table(Name = FruitBankPluginConst.IncomingInvoiceItemDbTableName)]
[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankPluginConst.IncomingInvoiceItemDbTableName)]
public sealed class IncomingInvoiceItem : MgEntityBase
{
/// <summary>A tükrözött számla-fej azonosítója (a mi oldalunkon).</summary>
[Column] public int IncomingInvoiceId { get; set; }
/// <summary>A tétel <c>TABLE_ID</c>-ja az InnVoice-ban — erre megy a tétel-szintű upsert.</summary>
[Column] public int InnVoiceTableId { get; set; }
[Column] public string Name { get; set; }
/// <summary>Cikkszám, ha van.</summary>
[Column] public string ItemNumber { get; set; }
/// <summary>VTSZ / SZJ szám.</summary>
[Column] public string VtszSzj { get; set; }
/// <summary>Az ÁFA-kulcs számszerűen (27 = 27%).</summary>
[Column] public decimal VatRate { get; set; }
/// <summary>Az ÁFA-kulcs szövegesen, ahogy az InnVoice adja (pl. "27%", "AAM").</summary>
/// <remarks>Nem redundáns: a mentes kulcsok (AAM, TAM, fordított adózás) mind 0-t adnak számszerűen,
/// a jogcímüket viszont csak ez a szöveg őrzi.</remarks>
[Column] public string VatText { get; set; }
[Column] public decimal UnitPrice { get; set; }
/// <summary>Az <see cref="UnitPrice"/> bruttó-e (az InnVoice <c>Brutto</c> jelzője).</summary>
[Column] public bool IsGrossUnitPrice { get; set; }
[Column] public decimal Quantity { get; set; }
[Column] public string Unit { get; set; }
/// <summary>Az egységárból levonandó kedvezmény összege.</summary>
[Column] public decimal DiscountAmount { get; set; }
[Column] public string Note { get; set; }
[Association(ThisKey = nameof(IncomingInvoiceId), OtherKey = nameof(IncomingInvoice.Id), CanBeNull = true)]
public IncomingInvoice IncomingInvoice { get; set; }
}
@@ -56,5 +56,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
/// <summary>Fulfilment of one preorder line from one delivery note.</summary>
public const string PreOrderFulfilmentDbTableName = "fbPreOrderFulfilment";
/// <summary>Supplier invoice mirrored from InnVoice (header).</summary>
public const string IncomingInvoiceDbTableName = "fbIncomingInvoice";
/// <summary>One line of a mirrored supplier invoice.</summary>
public const string IncomingInvoiceItemDbTableName = "fbIncomingInvoiceItem";
}
}
@@ -75,6 +75,26 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
/// Viber/email/diktálás integrációkhoz.
/// </summary>
public string OrderDraftApiKey { get; set; } = string.Empty;
// ── InnVoice ──────────────────────────────────────────────────────────────────
/// <summary>
/// InnVoice regisztrációs név (a `%regnev%` útvonal-szegmens), pl. "fruitbank".
/// </summary>
/// <remarks>
/// A hozzáférés 2026-08-25-ig a service-ek konstruktor-alapértékében állt, forráskódban. Innentől
/// beállítás — üresen hagyva az InnVoice hívások beszédes hibával állnak le, nem csendben.
/// </remarks>
public string InnVoiceCompanyName { get; set; } = string.Empty;
/// <summary>InnVoice API felhasználónév (Basic auth).</summary>
public string InnVoiceUsername { get; set; } = string.Empty;
/// <summary>InnVoice API jelszó (Basic auth).</summary>
public string InnVoicePassword { get; set; } = string.Empty;
/// <summary>InnVoice API alap-URL; éles értéke a gyári default.</summary>
public string InnVoiceBaseUrl { get; set; } = "https://api.innvoice.hu";
}
}
@@ -117,6 +117,8 @@ public class PluginNopStartup : INopStartup
services.AddScoped<ShippingCostDbTable>();
services.AddScoped<CostConsumptionDbTable>();
services.AddScoped<PreOrderFulfilmentDbTable>();
services.AddScoped<IncomingInvoiceDbTable>();
services.AddScoped<IncomingInvoiceItemDbTable>();
services.AddScoped<CostAttributionService>();
services.AddScoped<ShippingCostService>();
services.AddScoped<MarginCalculationService>();
@@ -164,6 +166,8 @@ public class PluginNopStartup : INopStartup
services.AddScoped<PendingMeasurementCheckoutFilter>();
services.AddScoped<InnVoiceApiService>();
services.AddScoped<InnVoiceOrderService>();
services.AddScoped<IncomingInvoiceSyncService>();
services.AddScoped<VatReportService>();
services.AddScoped<MeasurementService>();
services.AddScoped<ReplicateService>();
@@ -60,6 +60,8 @@ public partial class NameCompatibility : INameCompatibility
{ typeof(ShippingCost), FruitBankPluginConst.ShippingCostDbTableName},
{ typeof(CostConsumption), FruitBankPluginConst.CostConsumptionDbTableName},
{ typeof(PreOrderFulfilment), FruitBankPluginConst.PreOrderFulfilmentDbTableName},
{ typeof(IncomingInvoice), FruitBankPluginConst.IncomingInvoiceDbTableName},
{ typeof(IncomingInvoiceItem), FruitBankPluginConst.IncomingInvoiceItemDbTableName},
};
@@ -203,6 +203,12 @@
<None Update="Areas\Admin\Views\Extras\VoiceRecorder.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\IncomingInvoice\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\VatReport\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\InnVoiceOrderSync\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@@ -293,6 +293,28 @@ 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 vatReportMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "FruitBank",
Title = "ÁFA-kimutatás",
IconClass = "fas fa-percent",
Url = _adminMenu.GetMenuItemUrl("VatReport", "Index")
};
shippingConfigurationItem.ChildNodes.Insert(9, vatReportMenuItem);
var preordersRootMenuItem = new AdminMenuItem
{
Visible = true,
@@ -0,0 +1,231 @@
using AyCode.Core.Loggers;
using LinqToDB;
using Mango.Nop.Core.Loggers;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
#nullable enable
namespace Nop.Plugin.Misc.FruitBankPlugin.Services
{
/// <summary>
/// Az InnVoice bejövő (szállítói) számláinak tükrözése a saját táblánkba, a havi ÁFA-kimutatáshoz.
/// </summary>
/// <remarks>
/// <b>Miért tükrözünk, és nem kérdezünk élőben?</b> A szolgáltató kvótája szűk (id nélkül óránként 20, teljes
/// lekérdezésből 30 naponta 10), ezért egy felület, ami minden megnyitáskor kérdez, kiszámíthatatlanul
/// hiányos lenne. A tükör ezen felül a NÉZET stabilitását is adja: a kimutatás akkor is működik, ha az
/// InnVoice épp elérhetetlen.
/// <para><b>A vízjel maga az adat.</b> Nincs külön „utoljára szinkronizálva" beállítás: a következő
/// lekérdezés a tárolt <see cref="IncomingInvoice.SourceUpdatedAt"/> maximumától kér. Egy féloldalas futás
/// így nem tud átugrani számlákat — ami nem került be, az a maximumot sem tolta el.</para>
/// </remarks>
public class IncomingInvoiceSyncService
{
/// <summary>
/// Biztonsági ráhagyás a vízjelhez.
/// </summary>
/// <remarks>
/// A dokumentáció nem mondja meg, hogy az <c>updatedtime</c> határa nyílt-e vagy zárt, és az azonos
/// másodpercben módosult sorok sorsa is nyitott. Egy percet visszalépünk: néhány sort újra feldolgozunk,
/// ami az upsert miatt következmény nélküli — a kimaradó számla viszont némán hiányozna a kimutatásból.
/// </remarks>
private static readonly TimeSpan WatermarkSafetyMargin = TimeSpan.FromMinutes(1);
private readonly InnVoiceApiService _apiService;
private readonly IncomingInvoiceDbTable _invoices;
private readonly IncomingInvoiceItemDbTable _items;
private readonly ILogger _logger;
public IncomingInvoiceSyncService(
InnVoiceApiService apiService,
IncomingInvoiceDbTable invoices,
IncomingInvoiceItemDbTable items,
IEnumerable<IAcLogWriterBase> logWriters)
{
_apiService = apiService;
_invoices = invoices;
_items = items;
_logger = new Logger<IncomingInvoiceSyncService>(logWriters.ToArray());
}
/// <summary>
/// Lefuttatja a szinkront: üres táblánál teljes feltöltés, egyébként a vízjel óta módosultak.
/// </summary>
/// <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 storedWatermark = await _invoices.GetLatestSourceUpdatedAtAsync();
var watermark = forceFullLoad ? null : storedWatermark;
var result = new IncomingInvoiceSyncResult { WasFullLoad = watermark == null };
List<InnVoiceIncomingInvoice> fetched;
if (watermark == null)
{
// Első feltöltés. Ez az egyetlen hely, ahol a teljes lekérdezést hívjuk — a kvótája 30 naponta 10,
// és a túllépést a szolgáltató NEM hibával jelzi, hanem csendben kevesebb adattal.
_logger.Info("IncomingInvoiceSync: üres tükör, teljes lekérdezés indul (kvóta: 30 naponta 10).");
fetched = await _apiService.GetAllIncomingInvoicesAsync();
}
else
{
var since = watermark.Value - WatermarkSafetyMargin;
_logger.Detail($"IncomingInvoiceSync: növekményes lekérdezés {since:yyyy-MM-dd HH:mm:ss} óta.");
fetched = await _apiService.GetIncomingInvoicesByUpdateTimeAsync(since);
}
result.FetchedCount = fetched.Count;
if (fetched.Count == 0)
{
_logger.Info("IncomingInvoiceSync: nincs feldolgozandó számla.");
return result;
}
var tableIds = fetched.Select(invoice => invoice.TableId).Where(id => id > 0).Distinct().ToList();
var existingByTableId = (await _invoices.GetAllByInnVoiceTableIds(tableIds).ToListAsync())
.ToDictionary(invoice => invoice.InnVoiceTableId);
foreach (var source in fetched)
{
if (source.TableId <= 0)
{
// TABLE_ID nélkül nincs mire kulcsolni az upsertet: a következő futás duplikálná. Kihagyjuk,
// de megszámoljuk — a néma eldobás ugyanolyan rossz, mint a duplikátum.
result.SkippedCount++;
_logger.Warning($"IncomingInvoiceSync: TABLE_ID nélküli számla kihagyva (szállító: {source.SupplierName}, bizonylat: {source.DocumentNumber}).");
continue;
}
// 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
{
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;
}
}
catch (Exception ex)
{
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}, hibás: {result.FailedCount}, " +
$"teljes lekérdezés: {result.WasFullLoad}.");
return result;
}
/// <summary>A forrás mezőinek átvezetése a tükör-sorra. A forrás az igazság — mi csak másolunk.</summary>
private static void ApplySource(IncomingInvoice target, InnVoiceIncomingInvoice source)
{
target.InnVoiceTableId = source.TableId;
target.InnVoiceUniqueId = source.UniqueId;
target.SupplierId = source.SupplierId;
target.SupplierName = source.SupplierName;
target.SupplierCountryCode = source.SupplierCountryCode;
target.SupplierPostalCode = source.SupplierPostalCode;
target.SupplierCity = source.SupplierCity;
target.SupplierStreet = source.SupplierStreet;
target.DocumentNumber = source.DocumentNumber;
target.ReferenceNumber = source.ReferenceNumber;
target.InvoiceDate = source.InvoiceDate;
target.PerformanceDate = source.PerformanceDate;
target.DueDate = source.DueDate;
target.CurrencyCode = source.CurrencyCode;
target.ExchangeRate = source.ExchangeRate;
target.NetAmount = source.NetAmount;
target.VatAmount = source.VatAmount;
target.GrossAmount = source.GrossAmount;
target.PaidAmount = source.PaidAmount;
target.OutstandingAmount = source.OutstandingAmount;
target.PaymentMethod = source.PaymentMethod;
target.Note = source.Note;
target.IsSetAside = source.IsSetAside;
target.NavLink = source.NavLink;
var attachment = source.Attachments.FirstOrDefault();
target.AttachmentFileName = attachment?.FileName ?? string.Empty;
target.AttachmentUrl = attachment?.Url ?? string.Empty;
target.AttachmentCount = source.Attachments.Count;
target.SourceUpdatedAt = source.SourceUpdatedAt;
target.SyncedAt = DateTime.UtcNow;
}
/// <summary>
/// A számla tételeinek cseréje: a régiek törlése, az újak beszúrása.
/// </summary>
/// <remarks>
/// Tételenkénti összefésülés helyett teljes csere, mert a forrás az igazság, és a tétel-soroknak nincs
/// önálló életük — semmi nem hivatkozik rájuk. Az összefésülés csak bonyolultabb lenne, ugyanazért.
/// </remarks>
private async Task ReplaceItemsAsync(int incomingInvoiceId, InnVoiceIncomingInvoice source)
{
foreach (var obsolete in await _items.GetAllByIncomingInvoiceId(incomingInvoiceId).ToListAsync())
await _items.DeleteAsync(obsolete);
foreach (var item in source.Items)
{
await _items.InsertAsync(new IncomingInvoiceItem
{
IncomingInvoiceId = incomingInvoiceId,
InnVoiceTableId = item.TableId,
Name = item.Name,
ItemNumber = item.ItemNumber,
VtszSzj = item.VtszSzj,
VatRate = item.VatRate,
VatText = item.VatText,
UnitPrice = item.UnitPrice,
IsGrossUnitPrice = item.IsGrossUnitPrice,
Quantity = item.Quantity,
Unit = item.Unit,
DiscountAmount = item.DiscountAmount,
Note = item.Note
});
}
}
}
/// <summary>Egy szinkron-futás eredménye — az admin felület ezt írja ki.</summary>
public sealed class IncomingInvoiceSyncResult
{
/// <summary>Teljes lekérdezés futott (üres tükör), nem növekményes.</summary>
public bool WasFullLoad { get; set; }
public int FetchedCount { get; set; }
public int InsertedCount { get; set; }
public int UpdatedCount { get; set; }
/// <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; }
}
}
@@ -0,0 +1,263 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Xml.Linq;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services
{
/// <summary>
/// A BEJÖVŐ (szállítói) számlák lekérdezése az InnVoice-ból.
/// Dokumentáció: https://help.innvoice.hu/hc/hu/articles/360007786059
/// </summary>
/// <remarks>
/// ⚠️ <b>A kvóta némán csal.</b> A dokumentáció szerint a paraméter nélküli, teljes lekérdezés 30 naponta
/// tízszer futtatható, és „ezt meghaladó lekérdezések automatikusan csak az utolsó lekérdezéstől számított
/// változásokat fogja visszaadni" — tehát nem hibát kapunk, hanem KEVESEBB adatot, észrevétlenül. Ezért a
/// teljes lekérdezés kizárólag az első feltöltésre való (üres tábla), utána a
/// <see cref="GetIncomingInvoicesByUpdateTimeAsync"/> a munkaló. Id nélküli hívásból óránként 20 fér bele.
/// <para>A számokat és a dátumokat MINDIG invariáns kultúrával olvassuk: a szerver magyar területi
/// beállítással is futhat, ahol a "1234.56" tizedespontja ezresként vagy hibaként értelmeződne.</para>
/// </remarks>
public partial class InnVoiceApiService
{
/// <summary>Az InnVoice dátum-formátuma: „éééé.hh.nn." — a záró pont a forrásban is ott van.</summary>
private static readonly string[] InnVoiceDateFormats =
[
"yyyy.MM.dd.", "yyyy.MM.dd", "yyyy-MM-dd"
];
private static readonly string[] InnVoiceTimestampFormats =
[
"yyyy.MM.dd. HH:mm:ss", "yyyy.MM.dd HH:mm:ss", "yyyy-MM-dd HH:mm:ss",
"yyyyMMddHHmmss", "yyyy.MM.dd.", "yyyy.MM.dd"
];
/// <summary>
/// MINDEN bejövő számla. Csak az első feltöltésre — lásd az osztály megjegyzésében a kvótát.
/// </summary>
public Task<List<InnVoiceIncomingInvoice>> GetAllIncomingInvoicesAsync()
=> GetIncomingInvoicesFromUrlAsync($"{_baseUrl}/{_companyName}/incominginvoice");
/// <summary>A megadott időpont ÓTA módosult bejövő számlák — a növekményes szinkron útja.</summary>
public Task<List<InnVoiceIncomingInvoice>> GetIncomingInvoicesByUpdateTimeAsync(DateTime updatedSince)
=> GetIncomingInvoicesFromUrlAsync(
$"{_baseUrl}/{_companyName}/incominginvoice/updatedtime/{updatedSince.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}");
/// <summary>Egy nap TELJESÍTÉS-keltéjű bejövő számlái — célzott javításra egy gyanús hónapban.</summary>
public Task<List<InnVoiceIncomingInvoice>> GetIncomingInvoicesByPerformanceDateAsync(DateTime performanceDate)
=> GetIncomingInvoicesFromUrlAsync(
$"{_baseUrl}/{_companyName}/incominginvoice/teljesiteskelte/{performanceDate.ToString("yyyy.MM.dd.", CultureInfo.InvariantCulture)}");
/// <summary>Egy bejövő számla az InnVoice <c>TABLE_ID</c>-ja szerint; a kvótát nem terheli.</summary>
public async Task<InnVoiceIncomingInvoice> GetIncomingInvoiceByIdAsync(int tableId)
{
var invoices = await GetIncomingInvoicesFromUrlAsync($"{_baseUrl}/{_companyName}/incominginvoice/id/{tableId}");
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
{
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
return ParseIncomingInvoicesFromXml(await response.Content.ReadAsStringAsync());
}
catch (HttpRequestException ex)
{
throw new InnVoiceApiException($"Bejövő számlák lekérdezése nem sikerült: {ex.Message}", ex);
}
catch (Exception ex) when (ex is not InnVoiceApiException)
{
throw new InnVoiceApiException($"A bejövő számlák válaszának feldolgozása nem sikerült: {ex.Message}", ex);
}
}
private static List<InnVoiceIncomingInvoice> ParseIncomingInvoicesFromXml(string xml)
{
var document = XDocument.Parse(xml);
// 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.Ancestors().Any(ancestor => ancestor.Element("TABLE_ID") != null));
return [.. invoiceElements.Select(ParseIncomingInvoice)];
}
private static InnVoiceIncomingInvoice ParseIncomingInvoice(XElement element) => new()
{
TableId = ReadInt(element, "TABLE_ID"),
UniqueId = ReadString(element, "_UniqueID"),
SupplierId = ReadInt(element, "SzallitoID"),
SupplierName = ReadString(element, "SzallitoNev"),
SupplierCountryCode = ReadString(element, "SzallitoOrszag"),
SupplierPostalCode = ReadString(element, "SzallitoIrsz"),
SupplierCity = ReadString(element, "SzallitoTelep"),
SupplierStreet = ReadString(element, "SzallitoUtcaHsz"),
DocumentNumber = ReadString(element, "Bizszam"),
ReferenceNumber = ReadString(element, "Hivszam"),
InvoiceDate = ReadDate(element, "SzamlaKelte"),
PerformanceDate = ReadDate(element, "TeljesitesKelte"),
DueDate = ReadDate(element, "Hatarido"),
CurrencyCode = ReadString(element, "Devizanem"),
ExchangeRate = ReadDecimal(element, "Arfolyam"),
NetAmount = ReadDecimal(element, "NettoErtek"),
VatAmount = ReadDecimal(element, "AFAErtek"),
GrossAmount = ReadDecimal(element, "BruttoErtek"),
PaidAmount = ReadDecimal(element, "Befizetes"),
OutstandingAmount = ReadDecimal(element, "Hatralek"),
PaymentMethod = ReadString(element, "FizetesiMod"),
Note = ReadString(element, "Megjegyzes"),
IsSetAside = ReadInt(element, "Felretett") == 1,
NavLink = ReadString(element, "NavLink"),
SourceUpdatedAt = ReadTimestamp(element, "_UpdateTime"),
Items = [.. element.Elements("tetel").Select(ParseIncomingInvoiceItem)],
Attachments = [.. element.Elements("attachment").Select(attachment => new InnVoiceIncomingInvoiceAttachment
{
FileName = ReadString(attachment, "filename"),
ContentType = ReadString(attachment, "contenttype"),
Url = ReadString(attachment, "url")
})]
};
private static InnVoiceIncomingInvoiceItem ParseIncomingInvoiceItem(XElement element) => new()
{
TableId = ReadInt(element, "TABLE_ID"),
Name = ReadString(element, "TetelNev"),
ItemNumber = ReadString(element, "CikkSzam"),
VtszSzj = ReadString(element, "VTSZSZJ"),
VatRate = ReadDecimal(element, "AfaKulcs"),
VatText = ReadString(element, "AfaSzoveg"),
UnitPrice = ReadDecimal(element, "EgysegAr"),
IsGrossUnitPrice = ReadInt(element, "Brutto") == 1,
Quantity = ReadDecimal(element, "Mennyiseg"),
Unit = ReadString(element, "MennyisegEgyseg"),
DiscountAmount = ReadDecimal(element, "KedvezmenyOsszeg"),
Note = ReadString(element, "Megjegyzes")
};
private static string ReadString(XElement element, string name)
=> element.Element(name)?.Value?.Trim() ?? string.Empty;
private static int ReadInt(XElement element, string name)
=> int.TryParse(ReadString(element, name), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: 0;
private static decimal ReadDecimal(XElement element, string name)
=> decimal.TryParse(ReadString(element, name), NumberStyles.Number, CultureInfo.InvariantCulture, out var value)
? value
: 0m;
private static DateTime? ReadDate(XElement element, string name)
=> ParseWith(ReadString(element, name), InnVoiceDateFormats);
private static DateTime? ReadTimestamp(XElement element, string name)
=> ParseWith(ReadString(element, name), InnVoiceTimestampFormats);
/// <summary>Dátum beolvasása; ismeretlen alaknál <c>null</c> — a hívónak a hiány valós információ.</summary>
private static DateTime? ParseWith(string raw, string[] formats)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
return DateTime.TryParseExact(raw, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var exact)
? exact
: DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var loose)
? loose
: null;
}
}
// ── Wire-modellek: amit az InnVoice ad, a mi entitásaink előtt ──────────────────────
/// <summary>Egy bejövő számla az InnVoice válaszából. Nem entitás — a tükrözés bemenete.</summary>
public sealed class InnVoiceIncomingInvoice
{
public int TableId { get; set; }
public string UniqueId { get; set; } = string.Empty;
public int SupplierId { get; set; }
public string SupplierName { get; set; } = string.Empty;
public string SupplierCountryCode { get; set; } = string.Empty;
public string SupplierPostalCode { get; set; } = string.Empty;
public string SupplierCity { get; set; } = string.Empty;
public string SupplierStreet { get; set; } = string.Empty;
public string DocumentNumber { get; set; } = string.Empty;
public string ReferenceNumber { get; set; } = string.Empty;
public DateTime? InvoiceDate { get; set; }
public DateTime? PerformanceDate { get; set; }
public DateTime? DueDate { get; set; }
public string CurrencyCode { get; set; } = string.Empty;
/// <summary>A számlán rögzített árfolyam; 0 = nincs kitöltve. A dokumentáció nem említi ezt a mezőt.</summary>
public decimal ExchangeRate { get; set; }
public decimal NetAmount { get; set; }
public decimal VatAmount { get; set; }
public decimal GrossAmount { get; set; }
public decimal PaidAmount { get; set; }
public decimal OutstandingAmount { get; set; }
public string PaymentMethod { get; set; } = string.Empty;
public string Note { get; set; } = string.Empty;
public bool IsSetAside { get; set; }
public string NavLink { get; set; } = string.Empty;
public DateTime? SourceUpdatedAt { get; set; }
public List<InnVoiceIncomingInvoiceItem> Items { get; set; } = [];
public List<InnVoiceIncomingInvoiceAttachment> Attachments { get; set; } = [];
}
/// <summary>Egy tétel a bejövő számláról.</summary>
public sealed class InnVoiceIncomingInvoiceItem
{
public int TableId { get; set; }
public string Name { get; set; } = string.Empty;
public string ItemNumber { get; set; } = string.Empty;
public string VtszSzj { get; set; } = string.Empty;
public decimal VatRate { get; set; }
public string VatText { get; set; } = string.Empty;
public decimal UnitPrice { get; set; }
public bool IsGrossUnitPrice { get; set; }
public decimal Quantity { get; set; }
public string Unit { get; set; } = string.Empty;
public decimal DiscountAmount { get; set; }
public string Note { get; set; } = string.Empty;
}
/// <summary>A bejövő számla csatolmánya. Egyelőre csak a metaadatot tükrözzük, a fájlt nem töltjük le.</summary>
public sealed class InnVoiceIncomingInvoiceAttachment
{
public string FileName { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
}
}
@@ -0,0 +1,34 @@
using System;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services
{
/// <summary>
/// Az InnVoice hozzáférés beolvasásának közös szabályai — a kimenő (rendelés, számla) és a bejövő oldal
/// ugyanazt a hozzáférést használja, tehát az ellenőrzés is egy helyen lakik.
/// </summary>
internal static class InnVoiceCredentials
{
/// <summary>A gyári alap-URL; a beállítás üresen hagyva ez érvényes.</summary>
public const string DefaultBaseUrl = "https://api.innvoice.hu";
/// <summary>
/// Kötelező beállítás beolvasása.
/// </summary>
/// <remarks>
/// Hiánynál KIVÉTEL, nem üres string: üres felhasználónévvel a hívás 401-et kapna, és a hibaüzenet a
/// hálózatról szólna, nem a hiányzó beállításról. A kivétel megnevezi a mezőt és megmondja, hol kell
/// pótolni — ez az egyetlen ok, amiért a hiba hasznos.
/// </remarks>
public static string Require(string value, string settingName)
=> string.IsNullOrWhiteSpace(value)
? throw new InvalidOperationException(
$"Az InnVoice hozzáférés nincs beállítva: {nameof(FruitBankSettings)}.{settingName} üres. " +
"Admin → Bővítmények → FruitBank plugin → Konfigurálás → InnVoice.")
: value.Trim();
/// <summary>Alap-URL feloldása; üres beállításnál a gyári érték, záró perjel nélkül.</summary>
public static string ResolveBaseUrl(string configuredBaseUrl)
=> (string.IsNullOrWhiteSpace(configuredBaseUrl) ? DefaultBaseUrl : configuredBaseUrl.Trim())
.TrimEnd('/');
}
}
@@ -5,6 +5,7 @@ using System.Net.Http;
using System.Threading.Tasks;
using System.Xml.Linq;
using System.Linq;
using Nop.Services.Configuration;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services
{
@@ -19,13 +20,22 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
private readonly string _password;
private readonly string _baseUrl;
//public InnVoiceOrderService(string companyName = "apiteszt", string username = "apiteszt", string password = "dsjfluio4324hjhjfdhkjskjh213kjgsd", string baseUrl = "https://api.innvoice.hu")
public InnVoiceOrderService(string companyName = "fruitbank", string username = "fruitbank", string password = "YkKzM1dwJax0HTlPWlMABSqdSA", string baseUrl = "https://api.innvoice.hu")
/// <summary>
/// A hozzáférés a plugin beállításaiból jön (admin → Konfigurálás → InnVoice).
/// </summary>
/// <remarks>
/// 2026-08-25-ig a felhasználónév és a jelszó a konstruktor ALAPÉRTÉKÉBEN állt, forráskódban. Egy titok
/// nem lehet fordítási idejű konstans: nem cserélhető újrafordítás nélkül, és mindenki látja, aki a
/// repóhoz fér. Üres beállításnál a service beszédes hibával áll le — a néma, jogosulatlan hívás rosszabb.
/// </remarks>
public InnVoiceOrderService(ISettingService settingService)
{
_companyName = companyName ?? throw new ArgumentNullException(nameof(companyName));
_username = username ?? throw new ArgumentNullException(nameof(username));
_password = password ?? throw new ArgumentNullException(nameof(password));
_baseUrl = baseUrl.TrimEnd('/');
var settings = settingService.LoadSetting<FruitBankSettings>();
_companyName = InnVoiceCredentials.Require(settings.InnVoiceCompanyName, nameof(FruitBankSettings.InnVoiceCompanyName));
_username = InnVoiceCredentials.Require(settings.InnVoiceUsername, nameof(FruitBankSettings.InnVoiceUsername));
_password = InnVoiceCredentials.Require(settings.InnVoicePassword, nameof(FruitBankSettings.InnVoicePassword));
_baseUrl = InnVoiceCredentials.ResolveBaseUrl(settings.InnVoiceBaseUrl);
_httpClient = new HttpClient();
SetupAuthentication();
@@ -8,6 +8,7 @@ using System.Text.Json.Serialization;
using System.Xml.Linq;
using System.Linq;
using System.Text;
using Nop.Services.Configuration;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services
{
@@ -15,7 +16,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
/// Service for interacting with InnVoice Invoice API
/// API Documentation: https://help.innvoice.hu/hc/hu/articles/360003142839
/// </summary>
public class InnVoiceApiService
public partial class InnVoiceApiService
{
private readonly HttpClient _httpClient;
private readonly string _companyName;
@@ -23,12 +24,21 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
private readonly string _password;
private readonly string _baseUrl;
public InnVoiceApiService(string companyName = "apiteszt", string username = "apiteszt", string password = "dsjfluio4324hjhjfdhkjskjh213kjgsd", string baseUrl = "https://api.innvoice.hu")
/// <summary>
/// A hozzáférés a plugin beállításaiból jön (admin → Konfigurálás → InnVoice).
/// </summary>
/// <remarks>
/// 2026-08-25-ig itt egy <c>apiteszt</c> teszt-fiók állt a konstruktor alapértékében. A hozzáférés
/// beállítás, nem fordítási idejű konstans — üresen hagyva a service beszédes hibával áll le.
/// </remarks>
public InnVoiceApiService(ISettingService settingService)
{
_companyName = companyName ?? throw new ArgumentNullException(nameof(companyName));
_username = username ?? throw new ArgumentNullException(nameof(username));
_password = password ?? throw new ArgumentNullException(nameof(password));
_baseUrl = baseUrl.TrimEnd('/');
var settings = settingService.LoadSetting<FruitBankSettings>();
_companyName = InnVoiceCredentials.Require(settings.InnVoiceCompanyName, nameof(FruitBankSettings.InnVoiceCompanyName));
_username = InnVoiceCredentials.Require(settings.InnVoiceUsername, nameof(FruitBankSettings.InnVoiceUsername));
_password = InnVoiceCredentials.Require(settings.InnVoicePassword, nameof(FruitBankSettings.InnVoicePassword));
_baseUrl = InnVoiceCredentials.ResolveBaseUrl(settings.InnVoiceBaseUrl);
_httpClient = new HttpClient();
SetupAuthentication();
@@ -0,0 +1,547 @@
using LinqToDB;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
#nullable enable
namespace Nop.Plugin.Misc.FruitBankPlugin.Services
{
/// <summary>
/// Havi ÁFA-kimutatás: a bejövő oldal az InnVoice-ból tükrözött szállítói számlákból, a kimenő a saját
/// lezárt rendeléseinkből.
/// </summary>
/// <remarks>
/// <b>Belső tájékozódásra készül, nem bevallásnak.</b> Nem kezel fordított adózást, arányosítást, le nem
/// vonható tételt — a szám arra jó, hogy az adott hónap nagyságrendje látszódjon.
/// <para><b>A bejövő oldal a tükör.</b> A szállítmány-költségek (<c>fbShippingCost</c>) és a beszállítói
/// szállítólevelek NEM számítanak bele: azok is az InnVoice-on át jönnek, és kétszer venni őket
/// duplázás lenne.</para>
/// </remarks>
public class VatReportService(
IncomingInvoiceDbTable incomingInvoices,
IncomingInvoiceItemDbTable incomingInvoiceItems,
CostConsumptionDbTable costConsumptions,
FruitBankDbContext dbContext)
{
/// <summary>Ennél korábbi vagy ennél későbbi teljesítés-dátum adathiba, nem valós hónap.</summary>
private static readonly DateTime PlausibleFrom = new(2015, 1, 1);
/// <summary>
/// A kimutatás alsó határa: ennél korábbi időszak nem kérdezhető le.
/// </summary>
/// <remarks>
/// A bejövő oldal ugyan öt évre visszamenőleg megvan a tükörben, a KIMENŐ oldal viszont nem: az a
/// fogyasztás-sorokra épül, azok pedig csak az árrés-funkció élesítése óta keletkeznek. Egy korábbi
/// hónapra a riport valós bejövő áfát és nulla kimenőt mutatna — az egyenleg nem hiányzó áfát jelezne,
/// hanem hiányzó adatot. Ezt nem magyarázzuk el minden sorban, hanem el sem engedjük odáig.
/// </remarks>
public static readonly DateTime DataAvailableFrom = new(2026, 8, 1);
/// <summary>A HUF megnevezései az InnVoice-ban. A tükör azt tárolja, amit a forrás ad ("Ft").</summary>
private static readonly HashSet<string> HufCodes = new(StringComparer.OrdinalIgnoreCase) { "Ft", "HUF" };
/// <summary>A tételes lista felső határa — egy éves időszak több ezer sort adna.</summary>
private const int InvoiceListLimit = 500;
public async Task<VatReportModel> CalculateAsync(DateTime fromInclusive, DateTime toExclusive,
VatReportInvoiceFilter? filter = null)
{
var model = new VatReportModel
{
FromInclusive = fromInclusive,
ToExclusive = toExclusive,
Filter = filter ?? new VatReportInvoiceFilter()
};
model.MirrorSyncedAt = await incomingInvoices.GetLastSyncedAtAsync();
await AddIncomingAsync(model, fromInclusive, toExclusive);
await AddOutgoingAsync(model, fromInclusive, toExclusive);
await AddAnomaliesAsync(model);
model.Rows = [.. model.Rows.OrderBy(row => row.Year).ThenBy(row => row.Month)];
// ⚠️ A szűrő KIZÁRÓLAG a tételes listára hat, az összesítőkre soha. Egy szállítóra szűrt havi
// ÁFA-összeg részigazságot mondana, miközben teljesnek látszana — pont az a hibaosztály, ami ellen
// az egész kimutatás készült. A lista fejléce ki is írja, hogy a szűrés meddig ér.
model.InvoiceTotalCount = model.Invoices.Count;
model.Invoices = [.. model.Invoices
.Where(model.Filter.Matches)
.OrderByDescending(invoice => invoice.PerformanceDate)
.ThenBy(invoice => invoice.SupplierName)];
model.InvoiceMatchCount = model.Invoices.Count;
model.InvoiceListTruncated = model.Invoices.Count > InvoiceListLimit;
if (model.InvoiceListTruncated) model.Invoices = [.. model.Invoices.Take(InvoiceListLimit)];
return model;
}
// ── Bejövő oldal ─────────────────────────────────────────────────────────
private async Task AddIncomingAsync(VatReportModel model, DateTime fromInclusive, DateTime toExclusive)
{
var invoices = await incomingInvoices.GetAllByPerformanceDate(fromInclusive, toExclusive)
.Select(invoice => new
{
invoice.Id,
invoice.PerformanceDate,
invoice.InvoiceDate,
invoice.SupplierName,
invoice.DocumentNumber,
invoice.CurrencyCode,
invoice.ExchangeRate,
invoice.NetAmount,
invoice.VatAmount,
invoice.GrossAmount,
invoice.IsSetAside,
invoice.AttachmentFileName,
invoice.AttachmentUrl,
invoice.AttachmentCount
})
.ToListAsync();
if (invoices.Count == 0) return;
// A tétel csak az áfakulcs MEGÁLLAPÍTÁSÁHOZ kell — az összegeket a FEJ adja. A tételekből
// visszaszámolt érték (bruttó egységár, kedvezmény, kerekítés) nem egyezne a fejjel, és két
// egymásnak ellentmondó szám rosszabb, mint egy.
var invoiceIds = invoices.Select(invoice => invoice.Id).ToList();
var itemsByInvoice = (await incomingInvoiceItems.GetAllByIncomingInvoiceIds(invoiceIds)
.Select(item => new { item.IncomingInvoiceId, item.VatRate, item.VatText })
.ToListAsync())
.GroupBy(item => item.IncomingInvoiceId)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var invoice in invoices)
{
var row = model.RowFor(invoice.PerformanceDate!.Value);
var currency = string.IsNullOrWhiteSpace(invoice.CurrencyCode) ? "?" : invoice.CurrencyCode.Trim();
var isHuf = HufCodes.Contains(currency);
var items = itemsByInvoice.TryGetValue(invoice.Id, out var found) ? found : [];
var rates = items.Select(item => item.VatRate).Distinct().ToList();
var bucket = rates.Count switch
{
0 => VatRateBucket.NoItems,
1 => VatRateBucket.SingleRate,
_ => VatRateBucket.Mixed
};
// Devizánként külön sor, MINDEGYIK a saját devizájában. Átváltás nincs: az ismeretlen
// árfolyammal számolt érték mindig ugyanabba az irányba téved (MARGIN_ISSUES T4M6). A közösségi
// beszerzés áfája amúgy is 0, tehát az átváltás a FIZETENDŐ áfán semmit nem mozdítana — csak a
// nettó oszlopon, és azt hamis pontossággal.
// Devizás áfa forintosítása a SZÁMLÁN rögzített árfolyammal — nem piaci kurzussal. Egy euróban
// kiállított, magyar áfás számlán az áfa forintértéke a papíron szerepel; az árfolyam ezt hordozza.
var convertedVat = 0m;
var needsRate = false;
if (!isHuf)
{
if (invoice.ExchangeRate > 0)
convertedVat = invoice.VatAmount * invoice.ExchangeRate;
else if (invoice.VatAmount != 0)
// Csak ez torzít: a 0%-os közösségi beszerzésnél az árfolyam a fizetendőn semmit nem
// mozdít, tehát oda nem kérünk árfolyamot — az admin munkalapja így rövid marad.
needsRate = true;
}
row.AddCurrency(currency, isHuf, invoice.NetAmount, invoice.VatAmount, convertedVat, needsRate);
row.ConvertedVatAmount += convertedVat;
if (isHuf)
{
row.IncomingInvoiceCount++;
row.IncomingNetAmount += invoice.NetAmount;
row.IncomingVatAmount += invoice.VatAmount;
if (invoice.IsSetAside) row.SetAsideCount++;
// Kulcsonkénti bontás ÖSSZEG-becslés nélkül: az egykulcsos számla teljes fej-összege arra a
// kulcsra kerül, a vegyes kulcsú pedig külön vödörbe. A vödrök összege így pontosan a havi
// összeg — nincs kerekítési rés.
row.AddRateBucket(bucket, bucket == VatRateBucket.SingleRate ? rates[0] : null,
invoice.NetAmount, invoice.VatAmount);
}
var listRow = new VatReportInvoiceRow
{
Id = invoice.Id,
SupplierName = invoice.SupplierName ?? string.Empty,
DocumentNumber = invoice.DocumentNumber ?? string.Empty,
InvoiceDate = invoice.InvoiceDate,
PerformanceDate = invoice.PerformanceDate,
CurrencyCode = currency,
IsHuf = isHuf,
NetAmount = invoice.NetAmount,
VatAmount = invoice.VatAmount,
GrossAmount = invoice.GrossAmount,
IsSetAside = invoice.IsSetAside,
ExchangeRate = invoice.ExchangeRate,
AttachmentFileName = invoice.AttachmentFileName ?? string.Empty,
AttachmentUrl = invoice.AttachmentUrl ?? string.Empty,
AttachmentCount = invoice.AttachmentCount,
ConvertedVatAmount = convertedVat,
NeedsExchangeRate = needsRate,
ItemCount = items.Count,
RateLabel = bucket switch
{
VatRateBucket.SingleRate => FormatRate(rates[0], items[0].VatText),
VatRateBucket.Mixed => string.Join(" + ", rates.OrderBy(rate => rate).Select(rate => $"{rate:0.##}%")),
_ => "nincs tétel"
}
};
model.Invoices.Add(listRow);
if (needsRate) model.NeedsExchangeRate.Add(listRow);
}
}
/// <summary>A kulcs számmal; a nulla kulcsok jogcímét (AAM, ÁTK, fordított adózás) csak a szöveg őrzi.</summary>
private static string FormatRate(decimal rate, string? vatText)
=> rate == 0 && !string.IsNullOrWhiteSpace(vatText) ? vatText.Trim() : $"{rate:0.##}%";
// ── Kimenő oldal ─────────────────────────────────────────────────────────
private async Task AddOutgoingAsync(VatReportModel model, DateTime fromInclusive, DateTime toExclusive)
{
// A rendelés lezárásának pillanata a fogyasztás-sor Created mezője — a nop rendelés-dátumai közül
// egyik sem jelöli a lezárást (ADR 0002-R7Q2, 10. pont). Így a havi bontás egyezik az árrés-riportéval.
var consumptions = await costConsumptions.GetAllByPeriod(fromInclusive, toExclusive, false)
.Where(consumption => consumption.ConsumerTypeId == (int)CostConsumerType.OrderItem)
.Select(consumption => new { consumption.ConsumerId, consumption.Created })
.ToListAsync();
if (consumptions.Count == 0) return;
// ⚠️ EGY rendelés-tétel TÖBB rétegből is fogyhat, tehát több fogyasztás-sora van. A tétel áfáját
// soronként összegezve annyiszor számolnánk, ahány rétegből fogyott — ezért DISZTINKT tétel-id.
var itemIdsByMonth = consumptions
.GroupBy(consumption => (consumption.Created.Year, consumption.Created.Month))
.ToDictionary(g => g.Key, g => g.Select(x => x.ConsumerId).Distinct().ToList());
var allItemIds = itemIdsByMonth.Values.SelectMany(ids => ids).Distinct().ToList();
var orderItems = (await dbContext.OrderItemDtos.GetAll()
.Where(item => allItemIds.Contains(item.Id))
.Select(item => new { item.Id, item.PriceExclTax, item.PriceInclTax })
.ToListAsync())
.ToDictionary(item => item.Id);
foreach (var (period, itemIds) in itemIdsByMonth)
{
var row = model.RowFor(new DateTime(period.Year, period.Month, 1));
foreach (var itemId in itemIds)
{
if (!orderItems.TryGetValue(itemId, out var item))
{
// A rendelés-tétel eltűnt a fogyasztás-sor alól. Nem hallgatjuk el: a kimenő áfa
// enélkül csendben kevesebb lenne.
row.MissingOrderItemCount++;
continue;
}
row.OutgoingOrderItemCount++;
row.OutgoingNetAmount += item.PriceExclTax;
row.OutgoingVatAmount += item.PriceInclTax - item.PriceExclTax;
}
}
}
// ── Ami egyik hónapba sem esik bele ──────────────────────────────────────
private async Task AddAnomaliesAsync(VatReportModel model)
{
// Ezek a sorok MINDEN hónapból kimaradnak, tehát a riportban külön kell látszaniuk — különben
// ugyanaz a néma hiány keletkezik, ami ellen az egész tükrözés készült.
var plausibleTo = DateTime.UtcNow.AddYears(1);
model.WithoutPerformanceDate = await AsyncExtensions.CountAsync(
incomingInvoices.GetAllWithoutPerformanceDate());
model.ImplausiblePerformanceDate = await AsyncExtensions.CountAsync(
incomingInvoices.GetAll().Where(invoice => invoice.PerformanceDate != null
&& (invoice.PerformanceDate < PlausibleFrom
|| invoice.PerformanceDate > plausibleTo)));
}
}
/// <summary>Melyik vödörbe került a számla a kulcsonkénti bontásban.</summary>
public enum VatRateBucket
{
/// <summary>Minden tétele azonos áfakulcsú — a fej összege ahhoz a kulcshoz tartozik.</summary>
SingleRate = 0,
/// <summary>Több különböző kulcs egy számlán; a fej összegét nem bontjuk fel becsléssel.</summary>
Mixed = 1,
/// <summary>Nincs tükrözött tétele, tehát a kulcs ismeretlen.</summary>
NoItems = 2
}
/// <summary>Egy havi ÁFA-kimutatás.</summary>
public sealed class VatReportModel
{
public DateTime FromInclusive { get; set; }
public DateTime ToExclusive { get; set; }
/// <summary>A lekérdezhető legkorábbi nap — a felület ezt teszi a dátummezők alsó határának.</summary>
public DateTime DataAvailableFrom => VatReportService.DataAvailableFrom;
/// <summary>A kért kezdő dátum korábbi volt az adathatárnál, ezért felhúztuk.</summary>
public bool FromClamped { get; set; }
/// <summary>Mikor frissült utoljára a tükör az InnVoice-ból; a riport nem szinkronizál magától.</summary>
public DateTime? MirrorSyncedAt { get; set; }
public List<VatReportRow> Rows { get; set; } = [];
/// <summary>Az időszak számlái tételesen — a szám mögötti bizonylatok.</summary>
public List<VatReportInvoiceRow> Invoices { get; set; } = [];
/// <summary>A tételes listára ható szűrő. Az összesítőket NEM érinti.</summary>
public VatReportInvoiceFilter Filter { get; set; } = new();
/// <summary>Az időszak összes számlája, szűrés előtt.</summary>
public int InvoiceTotalCount { get; set; }
/// <summary>A szűrőre illeszkedő számlák száma (a listakorlát előtt).</summary>
public int InvoiceMatchCount { get; set; }
/// <summary>
/// Devizás számlák, amelyeken VAN áfa, de NINCS árfolyam — az admin munkalapja.
/// </summary>
/// <remarks>
/// A 0%-os közösségi beszerzés szándékosan nincs benne: ott az árfolyam a fizetendő áfán semmit nem
/// mozdít, tehát a lista rövid marad, és valóban csak azt kéri, ami számít.
/// </remarks>
public List<VatReportInvoiceRow> NeedsExchangeRate { get; set; } = [];
/// <summary>A tételes lista le lett vágva, mert az időszak túl sok számlát fog.</summary>
public bool InvoiceListTruncated { get; set; }
/// <summary>Teljesítés-dátum nélküli tükrözött számlák — egyik hónapba sem esnek bele.</summary>
public int WithoutPerformanceDate { get; set; }
/// <summary>Értelmetlen teljesítés-dátumú számlák (elgépelt évszám) — szintén kimaradnak minden hónapból.</summary>
public int ImplausiblePerformanceDate { get; set; }
public VatReportRow RowFor(DateTime date)
{
var row = Rows.FirstOrDefault(r => r.Year == date.Year && r.Month == date.Month);
if (row != null) return row;
row = new VatReportRow { Year = date.Year, Month = date.Month };
Rows.Add(row);
return row;
}
/// <summary>Forintos számlák áfája.</summary>
public decimal TotalIncomingVat => Rows.Sum(row => row.IncomingVatAmount);
/// <summary>A devizás számlák áfája forintra váltva, ahol volt árfolyam.</summary>
public decimal TotalConvertedVat => Rows.Sum(row => row.ConvertedVatAmount);
/// <summary>A teljes bejövő áfa forintban: a forintos és az átváltott devizás együtt.</summary>
public decimal TotalIncomingVatHuf => TotalIncomingVat + TotalConvertedVat;
public decimal TotalOutgoingVat => Rows.Sum(row => row.OutgoingVatAmount);
public decimal TotalBalance => TotalOutgoingVat - TotalIncomingVatHuf;
/// <summary>Devizánkénti összesítő az egész időszakra — ebből látszik, mit NEM tartalmaz a HUF-egyenleg.</summary>
public List<VatCurrencyRow> CurrencyTotals =>
[.. Rows.SelectMany(row => row.CurrencyRows)
.GroupBy(currency => currency.CurrencyCode)
.Select(g => new VatCurrencyRow
{
CurrencyCode = g.Key,
IsHuf = g.First().IsHuf,
InvoiceCount = g.Sum(x => x.InvoiceCount),
NetAmount = g.Sum(x => x.NetAmount),
VatAmount = g.Sum(x => x.VatAmount),
ConvertedVatAmount = g.Sum(x => x.ConvertedVatAmount),
MissingRateCount = g.Sum(x => x.MissingRateCount)
})
.OrderByDescending(currency => currency.IsHuf)
.ThenByDescending(currency => currency.InvoiceCount)];
}
/// <summary>Egy hónap sora.</summary>
public sealed class VatReportRow
{
public int Year { get; set; }
public int Month { get; set; }
// ── Bejövő (csak HUF: az egyenleg forintban értelmes) ──
public int IncomingInvoiceCount { get; set; }
public decimal IncomingNetAmount { get; set; }
public decimal IncomingVatAmount { get; set; }
public int SetAsideCount { get; set; }
// ── Kimenő ──
public int OutgoingOrderItemCount { get; set; }
public decimal OutgoingNetAmount { get; set; }
public decimal OutgoingVatAmount { get; set; }
/// <summary>Fogyasztás-sor, aminek a rendelés-tétele nem található.</summary>
public int MissingOrderItemCount { get; set; }
/// <summary>Devizánkénti bontás, saját pénznemben.</summary>
public List<VatCurrencyRow> CurrencyRows { get; set; } = [];
/// <summary>A devizás számlák áfája forintra váltva, ahol volt árfolyam.</summary>
public decimal ConvertedVatAmount { get; set; }
/// <summary>A hónap teljes bejövő áfája forintban.</summary>
public decimal IncomingVatHuf => IncomingVatAmount + ConvertedVatAmount;
/// <summary>Áfakulcsonkénti bontás a HUF-os számlákra; a vegyes és a tétel nélküli külön soron.</summary>
public List<VatRateRow> RateRows { get; set; } = [];
public decimal Balance => OutgoingVatAmount - IncomingVatHuf;
public void AddCurrency(string currencyCode, bool isHuf, decimal netAmount, decimal vatAmount,
decimal convertedVatAmount, bool needsRate)
{
var currencyRow = CurrencyRows.FirstOrDefault(c => c.CurrencyCode == currencyCode);
if (currencyRow == null)
{
currencyRow = new VatCurrencyRow { CurrencyCode = currencyCode, IsHuf = isHuf };
CurrencyRows.Add(currencyRow);
}
currencyRow.InvoiceCount++;
currencyRow.NetAmount += netAmount;
currencyRow.VatAmount += vatAmount;
currencyRow.ConvertedVatAmount += convertedVatAmount;
if (needsRate) currencyRow.MissingRateCount++;
}
public void AddRateBucket(VatRateBucket bucket, decimal? vatRate, decimal netAmount, decimal vatAmount)
{
var rateRow = RateRows.FirstOrDefault(r => r.Bucket == bucket && r.VatRate == vatRate);
if (rateRow == null)
{
rateRow = new VatRateRow { Bucket = bucket, VatRate = vatRate };
RateRows.Add(rateRow);
}
rateRow.InvoiceCount++;
rateRow.NetAmount += netAmount;
rateRow.VatAmount += vatAmount;
}
}
/// <summary>Egy deviza összesítője. Az összegek MINDIG a saját devizájukban — átváltás nincs.</summary>
public sealed class VatCurrencyRow
{
public string CurrencyCode { get; set; } = string.Empty;
public bool IsHuf { get; set; }
public int InvoiceCount { get; set; }
public decimal NetAmount { get; set; }
public decimal VatAmount { get; set; }
/// <summary>Az áfa forintra váltva, ahol volt árfolyam a számlán.</summary>
public decimal ConvertedVatAmount { get; set; }
/// <summary>Ennyi számlán VAN áfa, de nincs árfolyam — ennyivel hiányos a forintos összeg.</summary>
public int MissingRateCount { get; set; }
}
/// <summary>Egy áfakulcs (vagy a vegyes / tétel nélküli vödör) egy hónapon belül.</summary>
public sealed class VatRateRow
{
public VatRateBucket Bucket { get; set; }
/// <summary>Az áfakulcs; <c>null</c> a vegyes és a tétel nélküli vödörnél.</summary>
public decimal? VatRate { get; set; }
public int InvoiceCount { get; set; }
public decimal NetAmount { get; set; }
public decimal VatAmount { get; set; }
public string Label => Bucket switch
{
VatRateBucket.SingleRate => $"{VatRate:0.##}%",
VatRateBucket.Mixed => "vegyes kulcs",
_ => "nincs tétel"
};
}
/// <summary>
/// A tételes lista szűrője. Az összesítőkre szándékosan nem hat — lásd a <c>CalculateAsync</c> megjegyzését.
/// </summary>
public sealed class VatReportInvoiceFilter
{
/// <summary>Szabad szöveg: a szállító nevére ÉS a bizonylatszámra is illeszkedik.</summary>
/// <remarks>Egy mező kettő helyett: a keresés indulhat a névből és a bizonylatról is, és a felhasználó
/// nem gondolkodik azon, melyik dobozba írja.</remarks>
public string? Text { get; set; }
/// <summary>"huf" = csak forintos, "foreign" = csak devizás, egyébként mind.</summary>
public string? Currency { get; set; }
/// <summary>Csak az áfát tartalmazó számlák — a 0%-os közösségi beszerzés kiesik.</summary>
public bool OnlyWithVat { get; set; }
public bool IsActive => !string.IsNullOrWhiteSpace(Text)
|| Currency is "huf" or "foreign"
|| OnlyWithVat;
public bool Matches(VatReportInvoiceRow invoice)
{
if (OnlyWithVat && invoice.VatAmount == 0) return false;
if (Currency == "huf" && !invoice.IsHuf) return false;
if (Currency == "foreign" && invoice.IsHuf) return false;
if (string.IsNullOrWhiteSpace(Text)) return true;
var needle = Text.Trim();
return invoice.SupplierName.Contains(needle, StringComparison.OrdinalIgnoreCase)
|| invoice.DocumentNumber.Contains(needle, StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>Egy tükrözött bejövő számla a tételes listában.</summary>
public sealed class VatReportInvoiceRow
{
/// <summary>A tükrözött számla saját azonosítója — a csatolmány-proxy ezzel keresi vissza.</summary>
public int Id { get; set; }
public string SupplierName { get; set; } = string.Empty;
public string DocumentNumber { get; set; } = string.Empty;
public DateTime? InvoiceDate { get; set; }
public DateTime? PerformanceDate { get; set; }
public string CurrencyCode { get; set; } = string.Empty;
public bool IsHuf { get; set; }
public decimal NetAmount { get; set; }
public decimal VatAmount { get; set; }
public decimal GrossAmount { get; set; }
public bool IsSetAside { get; set; }
/// <summary>A számlán rögzített árfolyam; 0 = nincs kitöltve.</summary>
public decimal ExchangeRate { get; set; }
/// <summary>Az áfa forintra váltva; 0, ha nincs árfolyam vagy forintos a számla.</summary>
public decimal ConvertedVatAmount { get; set; }
/// <summary>Van áfa, de nincs árfolyam — ez a sor hiányzik a forintos összegből.</summary>
public bool NeedsExchangeRate { get; set; }
/// <summary>Az első csatolmány (beszkennelt bizonylat) neve és linkje; üres, ha nincs.</summary>
public string AttachmentFileName { get; set; } = string.Empty;
public string AttachmentUrl { get; set; } = string.Empty;
/// <summary>Csatolmányok száma a forrásban; 1-nél több esetén csak az elsőt mutatjuk.</summary>
public int AttachmentCount { get; set; }
public int ItemCount { get; set; }
/// <summary>Az áfakulcs(ok) olvasható alakja; nulla kulcsnál a jogcím szövege (AAM, ÁTK).</summary>
public string RateLabel { get; set; } = string.Empty;
}
}
-41
View File
@@ -1,41 +0,0 @@
# InnVoice integration — current state & planned migration
> Reference doc for `Nop.Plugin.Misc.FruitBankPlugin`. See [`README.md`](README.md) for the docs index.
> Scope: how the plugin pushes orders/invoices to the external **InnVoice** system from the admin order page.
## Where it lives
The admin **order edit** widget ([`Views/OrderAttributes.cshtml`](../Views/OrderAttributes.cshtml), rendered by `OrderAttributesViewComponent` into `AdminWidgetZones.OrderDetailsBlock`) hosts the InnVoice UI in its right-hand card *"Megrendelés beküldése Innvoice-ba"*.
Related server side: `InnVoiceApiService` (`Services/`), `InnVoiceOrderController` and `InvoiceController` (`Areas/Admin/Controllers/`).
## Current behaviour (order submission)
The active flow submits an **order** to InnVoice:
| UI | Endpoint |
|---|---|
| "Megrendelés beküldése" | `InnVoiceOrder/CreateOrder` |
| "Invoice adat ellenőrzése" | `InnVoiceOrder/GetOrderStatus` (also auto-run on page load) |
On success the card shows the InnVoice Order Table ID / Tech ID and a PDF link.
## Orphaned invoice JS (intentional, kept for the migration)
`OrderAttributes.cshtml` also contains a **full invoice-handling JS block** — handlers for `#createInvoiceBtn` / `#checkInvoiceBtn`, `displayInvoiceDetails(...)`, calls to `Invoice/CreateInvoice` and `Invoice/GetInvoiceStatus`, plus a page-load `GetInvoiceStatus` AJAX.
**These have no matching markup** (`#createInvoiceBtn`, `#invoiceDetails`, `#invoiceNumber`, `#invoicePdfLink`, … do not exist in the view), so the code is currently dead — the only live side effect is a silent, failing `GetInvoiceStatus` call on every order page load.
This is **not a bug to clean up yet**: it is groundwork for the planned migration below.
## Planned migration — direct invoice submission (draft)
We will switch the InnVoice integration from the current **order submission** to **direct invoice submission**, creating the invoice in **draft ("piszkozat") status** in InnVoice.
When we return to this:
- Wire the existing invoice JS to real buttons/markup in the InnVoice card (replacing or complementing the current order-submission buttons).
- Confirm `Invoice/CreateInvoice` creates the invoice in draft status.
- Decide the fate of the current `InnVoiceOrder/CreateOrder` order-submission path (keep, deprecate, or remove).
- Remove the wasted page-load `GetInvoiceStatus` call if it is not needed in the final design.
_Status: planned, not started. Recorded so the orphaned invoice code is understood as intentional._
@@ -0,0 +1,90 @@
# InnVoice — incoming (supplier) invoices
> Context: [`README.md`](README.md). Access and quota are stated there and not restated here.
Supplier invoices are mirrored from InnVoice into our own tables, and the monthly VAT report reads the mirror.
## Why a mirror and not a live query
- **The quota is narrow and lies.** A screen that queries on every open would be unpredictably incomplete, and nothing would say so.
- **The report survives an outage.** The mirror answers whether or not InnVoice is reachable.
- **The report needs data the API answers slowly** — several years, grouped by month.
## The watermark is the data
There is no "last synchronised" setting. The next incremental query asks from `MAX(IncomingInvoice.SourceUpdatedAt)` minus a one-minute safety margin.
- A half-finished run cannot skip invoices: what did not land also did not move the maximum.
- The safety margin exists because the provider does not document whether the `updatedtime` boundary is open or closed, nor what happens to rows modified within the same second. Re-processing a few rows is free — the upsert keys on `TABLE_ID` — while a silently missing invoice is not.
- **The hole:** a first load that ran degraded pins a wrong watermark and never heals on its own, because the watermark comes from the stored data. The escape hatch is the forced full load — [`MGFBANKPLUG-INNV-I-J8P5`](INNVOICE_ISSUES.md).
Full query only when the mirror is empty. `IncomingInvoiceSyncService.SyncAsync(forceFullLoad: true)` overrides that, and spends from the 10-per-30-days budget.
## Parsing the XML
**`TABLE_ID` is not unique in the document** — line items carry one too. Invoice elements are recognised structurally:
```csharp
document.Descendants().Where(element => element.Element("TABLE_ID") != null
&& !element.Ancestors().Any(ancestor => ancestor.Element("TABLE_ID") != null))
```
An invoice is an element with a `TABLE_ID` and **no ancestor** that has one. Matching on the element name instead parsed `<reszlet>` line items as invoices, which then collided on duplicate keys. The provider documents the fields, not the enclosing element names, so the shape has to be observed: `IncomingInvoiceController.Raw` returns the response unprocessed for exactly that.
Numbers and dates are read with **invariant culture** — the server may run under a Hungarian locale, where `1234.56` would read as thousands or fail.
## Endpoints used
| Method | URL | Note |
|---|---|---|
| `GetAllIncomingInvoicesAsync` | `/incominginvoice` | first load only — the 10-per-30-days query |
| `GetIncomingInvoicesByUpdateTimeAsync` | `/incominginvoice/updatedtime/{yyyyMMddHHmmss}` | the workhorse |
| `GetIncomingInvoicesByPerformanceDateAsync` | `/incominginvoice/teljesiteskelte/{yyyy.MM.dd.}` | targeted repair of one suspect day |
| `GetIncomingInvoiceByIdAsync` | `/incominginvoice/id/{tableId}` | does not spend from the id-less budget |
## Storage
`fbIncomingInvoice` (`IncomingInvoice`) + `fbIncomingInvoiceItem` (`IncomingInvoiceItem`), keyed on `InnVoiceTableId`. Header totals are stored as the provider states them; items carry the VAT rates.
Two facts the column list does not convey:
- **`ExchangeRate` may be 0** — the provider fills it only when the admin does. Zero means *unknown*, never 1. Treating it as 1 turns a EUR invoice into a HUF one at a fraction of its value; the same mistake in the margin feature produced 98.69% margins (`MGFBANKPLUG-MARGIN-B-T4M6`).
- **Only the first attachment is stored**, with `AttachmentCount` beside it. One PDF per invoice is the observed reality; the count is what makes the loss visible rather than silent.
`AttachmentUrl` carries a tenant-level access token. It is never rendered into the page, the page source or the CSV export — the viewer goes through `IncomingInvoiceController.Attachment(int id)`, which resolves the URL from our own database by invoice id and streams the bytes back.
Two things have to hold for the browser to *display* the file rather than download it, and neither is optional:
- **A content type the browser can render.** InnVoice sends `application/octet-stream` even for a PDF, so the upstream header is the least trustworthy source. `ResolveContentType` reads our own `AttachmentFileName` first, then sniffs the leading bytes (`%PDF`, PNG, JPEG, TIFF), and falls back to the upstream header only when it says something other than the generic binary type.
- **An explicit `Content-Disposition: inline`.** Omitting the filename makes ASP.NET send no disposition header at all, which in principle means inline — in practice the browser then decides on the content type alone.
`Attachment(int id, bool raw: true)` returns the upstream headers, the byte count and the leading bytes as plain text instead of the file. It exists because what the provider actually sends is not derivable from its documentation.
## Sync
`IncomingInvoiceSyncService.SyncAsync(bool forceFullLoad)`, driven from admin → *Bejövő számlák*:
| Button | Effect |
|---|---|
| Szinkronizálás | incremental from the watermark (full load if the mirror is empty) |
| Teljes újraszinkron | forced full load — spends from the 10-per-30-days budget |
| Nyers válasz | the unprocessed XML, for diagnosing structure |
Per-row `try`/`catch`: one bad invoice cannot take the rest of the batch with it. Without it the first collision dropped the remainder while the watermark had already moved, so the lost rows would not have come back on the next run. Rows without a `TABLE_ID` are skipped and **counted** (`SkippedCount`), failures counted separately (`FailedCount`) — a silent drop is as bad as a duplicate.
**Nothing runs this automatically.** The report does not sync on open, and says so in its header along with the mirror's age. Scheduling it: [`MGFBANKPLUG-INNV-T-M6K3`](INNVOICE_TODO.md).
## Monthly VAT report
`VatReportService` + admin → *ÁFA-kimutatás* (`VatReportController`, `Areas/Admin/Views/VatReport/Index.cshtml`).
**Internal orientation, not a tax return.** No reverse charge, no apportionment, no non-deductible items. The number exists to show the month's order of magnitude.
- **The month is the performance date** (`PerformanceDate`), not the invoice date.
- **Incoming = InnVoice invoices only.** Shipping costs (`fbShippingCost`) and supplier delivery notes are deliberately excluded: they also arrive through InnVoice, and counting both would double them.
- **Outgoing comes from our own side**, from the cost-consumption rows.
- **`DataAvailableFrom = 2026-08-01`** is a hard floor. The incoming side goes back years in the mirror; the outgoing side starts when the margin feature went live. An earlier month would show real incoming VAT against zero outgoing — a balance that looks like missing tax but is missing data.
- **Set-aside invoices** (`IsSetAside`) are shown, flagged.
- **Per-currency rows.** A non-HUF invoice contributes to the HUF total only through its `ExchangeRate`; with none, the row is listed under *"Ezekre kell árfolyam"* and excluded from the HUF figure rather than guessed. Only invoices with **non-zero VAT** need a rate — a 0% community acquisition changes nothing.
- **Rate buckets** are `SingleRate` / `Mixed` / `NoItems`, derived from the items but summing to the header total, so the breakdown has no rounding gap against the monthly figure. Header totals stay authoritative.
- **The text/currency filter touches the itemised list only** — never the monthly, per-currency or per-rate summaries. A supplier-filtered monthly VAT figure would state a half-truth while looking complete.
@@ -0,0 +1,31 @@
# INNVOICE — ISSUES
> ID format, Status vocabulary, type codes and archival are **not restated here** — see [`TOPIC_CODES.md`](../../.github/TOPIC_CODES.md) (→ framework registry).
Context: [`README.md`](README.md).
---
## MGFBANKPLUG-INNV-I-D4W9: Orphaned invoice JS on the order page costs one failing request per load
**Status:** Open · **Priority:** P3 · **Type:** I · **2026-08-26**
`Views/OrderAttributes.cshtml` carries a complete invoice-handling JS block — handlers for `#createInvoiceBtn` / `#checkInvoiceBtn`, `displayInvoiceDetails(...)`, calls to `Invoice/CreateInvoice` and `Invoice/GetInvoiceStatus` — with **no matching markup**: `#createInvoiceBtn`, `#invoiceDetails`, `#invoiceNumber`, `#invoicePdfLink` do not exist in the view.
The code is therefore dead except for one live side effect: a page-load `GetInvoiceStatus` AJAX call that fails silently on every order edit page.
> **Note:** the block is kept deliberately — it is groundwork for [`MGFBANKPLUG-INNV-T-B2N7`](INNVOICE_TODO.md). Deleting it is not the fix; wiring it, or removing the page-load call alone, is.
---
## MGFBANKPLUG-INNV-I-J8P5: A degraded first load pins a wrong watermark and cannot heal on its own
**Status:** Open · **Priority:** P2 · **Type:** I · **2026-08-26**
The incremental query starts from `MAX(IncomingInvoice.SourceUpdatedAt)`, which is derived from the stored data. If the first full load ran while the provider's 10-per-30-days budget was already spent, it returned deltas only — the mirror is partial, and the watermark now sits at the newest row that *did* arrive. Every later incremental run asks from there, so the missing older invoices are never requested again, and nothing in the data says they are missing.
**Affected:** `Services/IncomingInvoiceSyncService.cs``SyncAsync`.
**Mitigation in place:** the forced full load (`SyncAsync(forceFullLoad: true)`, admin → *Bejövő számlák* → "Teljes újraszinkron") re-requests everything and re-seats the watermark. It is manual and spends from the same budget, so it is a repair tool, not a guard.
**What would close this:** a check that does not depend on the watermark — comparing the mirror's invoice count for a settled past month against `/incominginvoice/teljesiteskelte/` for a sampled day in it, and reporting a mismatch.
@@ -0,0 +1,37 @@
# INNVOICE — TODO
> Companion to [`README.md`](README.md). Topic `INNV`, prefix `MGFBANKPLUG` → entry IDs `MGFBANKPLUG-INNV-T-<RAND>`.
> ID format, Status vocabulary, type codes and archival are **not restated here** — see [`TOPIC_CODES.md`](../../.github/TOPIC_CODES.md) (→ framework registry).
## Active entries
## MGFBANKPLUG-INNV-T-M6K3: Scheduled incremental sync for the incoming mirror
**Status:** Open · **Priority:** P2 · **Type:** T · **2026-08-26**
The mirror only refreshes when someone presses "Szinkronizálás" on admin → *Bejövő számlák*. The VAT report reads a mirror that may be days old; it states its age in the header, which makes the staleness visible but not smaller.
**Not on page load.** The id-less budget is 20 requests per hour and overrun degrades silently, so a sync per report open would exhaust it unpredictably and then serve incomplete data without saying so.
### Steps
- [ ] A nopCommerce `IScheduleTask` implementation calling `IncomingInvoiceSyncService.SyncAsync(false)`. **The plugin has none today** — this is the first, so registration on install and removal on uninstall are part of the work.
- [ ] Hourly period: 24 requests a day out of 480, and the report is never more than an hour behind.
- [ ] Failures reach the log with the counts the sync already produces (`FetchedCount`, `InsertedCount`, `UpdatedCount`, `SkippedCount`, `FailedCount`); a scheduled task that fails quietly is worse than the manual button.
---
## MGFBANKPLUG-INNV-T-B2N7: Switch the outbound direction from order submission to direct draft-invoice submission
**Status:** Open · **Priority:** P3 · **Type:** T · **2026-08-26**
The outbound path currently submits an **order** to InnVoice (`InnVoiceOrder/CreateOrder`). The intent is to submit the **invoice** directly, created in draft ("piszkozat") status.
The dead invoice JS in `Views/OrderAttributes.cshtml` is groundwork for this and is kept for that reason ([`MGFBANKPLUG-INNV-I-D4W9`](INNVOICE_ISSUES.md)).
### Steps
- [ ] Wire the existing invoice JS to real buttons and markup in the InnVoice card, replacing or complementing the order-submission buttons.
- [ ] Confirm `Invoice/CreateInvoice` creates the invoice in draft status.
- [ ] Decide the fate of the `InnVoiceOrder/CreateOrder` path — keep, deprecate or remove.
- [ ] Remove the page-load `GetInvoiceStatus` call if the final design does not need it.
@@ -0,0 +1,49 @@
/* =============================================================================
BEJÖVŐ SZÁMLÁK csatolmány (bizonylatkép) hivatkozása
-----------------------------------------------------------------------------
Három oszlop a fbIncomingInvoice-ra. Idempotens: újrafuttatva nem csinál semmit.
🛡 USE FruitBank_DEV;-re állítva. PROD-hoz írd át a USE-t a PROD adatbázis
nevére.
MIÉRT: az InnVoice a bejövő számla mellé adja a beszkennelt bizonylatot
(attachment/filename + url). A kimutatásból egy kattintással megnyithatóvá
tesszük enélkül a szám mögé nézni külön rendszerbe kellene átmenni.
CSAK AZ ELSŐ csatolmányt tároljuk, a darabszámmal együtt. Az API többet is
adhat, de a gyakorlatban egy PDF van számlánként; egy külön tábla ennyiért
nem áll meg. Ha egyszer valóban több kell, az AttachmentCount mutatja, hol
veszítünk információt a mező azért van, hogy ez a veszteség LÁTHATÓ
legyen, ne néma.
Az URL a bérlő-szintű hozzáférési tokent tartalmazza. Admin-only felületen
jelenítjük meg, és nem adjuk tovább.
Feltöltés: admin Bejövő számlák "Teljes újraszinkron" (a növekményes
lekérdezés csak a MÓDOSULT számlákat hozza).
============================================================================= */
USE FruitBank_DEV; -- PROD-hoz írd át a PROD adatbázis nevére!
SET NOCOUNT ON;
GO
IF COL_LENGTH('dbo.fbIncomingInvoice', 'AttachmentFileName') IS NULL
ALTER TABLE dbo.fbIncomingInvoice ADD AttachmentFileName nvarchar(400) NULL;
GO
IF COL_LENGTH('dbo.fbIncomingInvoice', 'AttachmentUrl') IS NULL
ALTER TABLE dbo.fbIncomingInvoice ADD AttachmentUrl nvarchar(1000) NULL;
GO
IF COL_LENGTH('dbo.fbIncomingInvoice', 'AttachmentCount') IS NULL
ALTER TABLE dbo.fbIncomingInvoice
ADD AttachmentCount int NOT NULL
CONSTRAINT DF_fbIncomingInvoice_AttachmentCount DEFAULT (0);
GO
/* Ellenőrzés — mindhárom oszlopnak léteznie kell (3). */
SELECT (CASE WHEN COL_LENGTH('dbo.fbIncomingInvoice', 'AttachmentFileName') IS NULL THEN 0 ELSE 1 END)
+ (CASE WHEN COL_LENGTH('dbo.fbIncomingInvoice', 'AttachmentUrl') IS NULL THEN 0 ELSE 1 END)
+ (CASE WHEN COL_LENGTH('dbo.fbIncomingInvoice', 'AttachmentCount') IS NULL THEN 0 ELSE 1 END)
AS AttachmentColumns;
GO
@@ -0,0 +1,49 @@
/* =============================================================================
BEJÖVŐ SZÁMLÁK árfolyam oszlop
-----------------------------------------------------------------------------
Egy oszlop a fbIncomingInvoice-ra. Idempotens: újrafuttatva nem csinál semmit.
🛡 USE FruitBank_DEV;-re állítva. PROD-hoz írd át a USE-t a PROD adatbázis
nevére.
MIÉRT: a devizás számlák áfáját forintosítani kell, különben a havi egyenleg
hiányos. Az InnVoice a számlán tárolja az árfolyamot (a felületén az
"Árfolyam (Ft)" mező, a "teljesítés napja" gombbal töltve) a válaszban
Arfolyam néven jön, a dokumentációja nem említi.
A DEFAULT 0 SZÁNDÉKOS, és NEM 1. A hiányzó árfolyamot 1-nek venni azt
jelentené, hogy egy eurós összeget forintnak olvasunk: a hiba mindig
ugyanabba az irányba visz, és nem tűnik hibának. A 0 = "nem ismert", és a
riport külön listán kéri be. (Ugyanez a lecke: MGFBANKPLUG-MARGIN-B-T4M6.)
A meglévő sorok feltöltése: admin Bejövő számlák "Teljes újraszinkron".
A növekményes lekérdezés csak a MÓDOSULT számlákat hozza, tehát a 6551 régi
sor magától nem kapná meg az értéket.
============================================================================= */
USE FruitBank_DEV; -- PROD-hoz írd át a PROD adatbázis nevére!
SET NOCOUNT ON;
GO
IF COL_LENGTH('dbo.fbIncomingInvoice', 'ExchangeRate') IS NULL
ALTER TABLE dbo.fbIncomingInvoice
ADD ExchangeRate decimal(18,6) NOT NULL
CONSTRAINT DF_fbIncomingInvoice_ExchangeRate DEFAULT (0);
GO
/* -----------------------------------------------------------------------------
Ellenőrzés az oszlopnak léteznie kell (1), és látszik, hány devizás számla
vár még árfolyamra ÚGY, hogy az áfája nem nulla. Csak ezek torzítják a
forintos egyenleget: a 0%-os közösségi beszerzésnél az árfolyam a fizetendő
áfán semmit nem mozdít.
----------------------------------------------------------------------------- */
SELECT CASE WHEN COL_LENGTH('dbo.fbIncomingInvoice', 'ExchangeRate') IS NULL THEN 0 ELSE 1 END AS ExchangeRateColumnExists;
GO
IF COL_LENGTH('dbo.fbIncomingInvoice', 'ExchangeRate') IS NOT NULL
SELECT COUNT(*) AS NeedsExchangeRate
FROM dbo.fbIncomingInvoice
WHERE CurrencyCode NOT IN ('Ft', 'HUF')
AND VatAmount <> 0
AND ExchangeRate <= 0;
GO
@@ -0,0 +1,139 @@
/* =============================================================================
BEJÖVŐ SZÁMLÁK (InnVoice tükör) séma
-----------------------------------------------------------------------------
Két új tábla (fbIncomingInvoice, fbIncomingInvoiceItem). Meglévő táblát nem
érint. Idempotens: újrafuttatva nem csinál semmit.
🛡 USE FruitBank_DEV;-re állítva. PROD-hoz írd át a USE-t a PROD adatbázis
nevére. Séma-változás, ezért a futtatás előtt DB-mentés ajánlott.
Pénz = decimal(18,4) (a float kerekítési hibája pénznél nem vállalható)
Kulcs = decimal(5,2) (áfakulcs százalékban: 27.00 = 27%)
Az oszlopnevek PONTOSAN a C# property-nevek: a LinqToDB név szerint mappel.
============================================================================= */
USE FruitBank_DEV; -- PROD-hoz írd át a PROD adatbázis nevére!
SET NOCOUNT ON;
GO
/* -----------------------------------------------------------------------------
1) fbIncomingInvoice a szállítói számla feje, az InnVoice-ból tükrözve
-----------------------------------------------------------------------------
InnVoiceTableId: az InnVoice TABLE_ID-ja. Ez a tükrözés természetes kulcsa
erre megy az upsert, ezért EGYEDI indexet kap. Enélkül egy megismételt
szinkron duplikálná a sorokat, és a havi összeg némán a kétszerese lenne.
PerformanceDate: a teljesítés kelte EZ sorolja a számlát a havi
ÁFA-kimutatás hónapjába (az ÁFA is teljesítés szerint jár). Nullable, mert
az InnVoice üresen is adhatja; az ilyen sor egyik hónapba sem esik, és a
kimutatásnak külön kell jeleznie.
SourceUpdatedAt: az InnVoice _UpdateTime mezője. Ennek a MAXIMUMA a
növekményes szinkron vízjele külön vízjel-beállítást ezért nem tartunk.
----------------------------------------------------------------------------- */
IF OBJECT_ID('dbo.fbIncomingInvoice', 'U') IS NULL
BEGIN
CREATE TABLE dbo.fbIncomingInvoice (
Id int IDENTITY(1,1) NOT NULL CONSTRAINT PK_fbIncomingInvoice PRIMARY KEY,
InnVoiceTableId int NOT NULL,
InnVoiceUniqueId nvarchar(100) NULL,
SupplierId int NOT NULL CONSTRAINT DF_fbIncomingInvoice_SupplierId DEFAULT (0),
SupplierName nvarchar(400) NULL,
SupplierCountryCode nvarchar(10) NULL,
SupplierPostalCode nvarchar(20) NULL,
SupplierCity nvarchar(200) NULL,
SupplierStreet nvarchar(400) NULL,
DocumentNumber nvarchar(100) NULL,
ReferenceNumber nvarchar(100) NULL,
InvoiceDate datetime NULL,
PerformanceDate datetime NULL,
DueDate datetime NULL,
CurrencyCode nvarchar(10) NULL,
NetAmount decimal(18,4) NOT NULL CONSTRAINT DF_fbIncomingInvoice_NetAmount DEFAULT (0),
VatAmount decimal(18,4) NOT NULL CONSTRAINT DF_fbIncomingInvoice_VatAmount DEFAULT (0),
GrossAmount decimal(18,4) NOT NULL CONSTRAINT DF_fbIncomingInvoice_GrossAmount DEFAULT (0),
PaidAmount decimal(18,4) NOT NULL CONSTRAINT DF_fbIncomingInvoice_PaidAmount DEFAULT (0),
OutstandingAmount decimal(18,4) NOT NULL CONSTRAINT DF_fbIncomingInvoice_Outstanding DEFAULT (0),
PaymentMethod nvarchar(200) NULL,
Note nvarchar(max) NULL,
IsSetAside bit NOT NULL CONSTRAINT DF_fbIncomingInvoice_IsSetAside DEFAULT (0),
NavLink nvarchar(1000) NULL,
SourceUpdatedAt datetime NULL,
SyncedAt datetime NOT NULL CONSTRAINT DF_fbIncomingInvoice_SyncedAt DEFAULT (GETUTCDATE()),
Created datetime NOT NULL CONSTRAINT DF_fbIncomingInvoice_Created DEFAULT (GETUTCDATE()),
Modified datetime NOT NULL CONSTRAINT DF_fbIncomingInvoice_Modified DEFAULT (GETUTCDATE())
);
END
GO
/* Az upsert kulcsa. UNIQUE, mert a duplikátum itt nem kellemetlenség, hanem
HAMIS SZÁM: a havi ÁFA-összeg a duplikált sorral együtt adódna össze. */
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_fbIncomingInvoice_InnVoiceTableId' AND object_id = OBJECT_ID('dbo.fbIncomingInvoice'))
CREATE UNIQUE INDEX UX_fbIncomingInvoice_InnVoiceTableId ON dbo.fbIncomingInvoice(InnVoiceTableId);
GO
/* A kimutatás a teljesítés kelte szerint szűr — ez az egyetlen forró lekérdezése. */
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_fbIncomingInvoice_PerformanceDate' AND object_id = OBJECT_ID('dbo.fbIncomingInvoice'))
CREATE INDEX IX_fbIncomingInvoice_PerformanceDate ON dbo.fbIncomingInvoice(PerformanceDate);
GO
/* A vízjel MAX(SourceUpdatedAt)-ként áll elő minden szinkron elején. */
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_fbIncomingInvoice_SourceUpdatedAt' AND object_id = OBJECT_ID('dbo.fbIncomingInvoice'))
CREATE INDEX IX_fbIncomingInvoice_SourceUpdatedAt ON dbo.fbIncomingInvoice(SourceUpdatedAt);
GO
/* -----------------------------------------------------------------------------
2) fbIncomingInvoiceItem a számla tételei
-----------------------------------------------------------------------------
Egyetlen célt szolgál: az ÁFA-kulcsonkénti bontást. Egy magyar számlán
keveredhet a 27% és az 5%/0%, amit a fej egyetlen VatAmount mezője nem tud
leképezni.
A tételekből visszaszámolt összeg NEM feltétlenül egyezik a fejjel: az
egységár lehet bruttó (IsGrossUnitPrice), van kedvezmény, és kerekítés is
közbejön. A kimutatásban ezért a FEJ az igazság, a tétel csak a bontás.
VatText: a mentes kulcsok (AAM, TAM, fordított adózás) mind 0-t adnak
számszerűen a jogcímüket csak ez a szöveg őrzi.
----------------------------------------------------------------------------- */
IF OBJECT_ID('dbo.fbIncomingInvoiceItem', 'U') IS NULL
BEGIN
CREATE TABLE dbo.fbIncomingInvoiceItem (
Id int IDENTITY(1,1) NOT NULL CONSTRAINT PK_fbIncomingInvoiceItem PRIMARY KEY,
IncomingInvoiceId int NOT NULL,
InnVoiceTableId int NOT NULL CONSTRAINT DF_fbIncomingInvoiceItem_TableId DEFAULT (0),
Name nvarchar(1000) NULL,
ItemNumber nvarchar(100) NULL,
VtszSzj nvarchar(50) NULL,
VatRate decimal(5,2) NOT NULL CONSTRAINT DF_fbIncomingInvoiceItem_VatRate DEFAULT (0),
VatText nvarchar(100) NULL,
UnitPrice decimal(18,4) NOT NULL CONSTRAINT DF_fbIncomingInvoiceItem_UnitPrice DEFAULT (0),
IsGrossUnitPrice bit NOT NULL CONSTRAINT DF_fbIncomingInvoiceItem_IsGross DEFAULT (0),
Quantity decimal(18,4) NOT NULL CONSTRAINT DF_fbIncomingInvoiceItem_Quantity DEFAULT (0),
Unit nvarchar(50) NULL,
DiscountAmount decimal(18,4) NOT NULL CONSTRAINT DF_fbIncomingInvoiceItem_Discount DEFAULT (0),
Note nvarchar(max) NULL,
CONSTRAINT FK_fbIncomingInvoiceItem_Invoice FOREIGN KEY (IncomingInvoiceId)
REFERENCES dbo.fbIncomingInvoice(Id) ON DELETE CASCADE
);
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_fbIncomingInvoiceItem_InvoiceId' AND object_id = OBJECT_ID('dbo.fbIncomingInvoiceItem'))
CREATE INDEX IX_fbIncomingInvoiceItem_InvoiceId ON dbo.fbIncomingInvoiceItem(IncomingInvoiceId);
GO
/* -----------------------------------------------------------------------------
Ellenőrzés mindkét sornak 1-et kell adnia.
----------------------------------------------------------------------------- */
SELECT 'fbIncomingInvoice' AS TableName, CASE WHEN OBJECT_ID('dbo.fbIncomingInvoice', 'U') IS NULL THEN 0 ELSE 1 END AS Exists_;
SELECT 'fbIncomingInvoiceItem' AS TableName, CASE WHEN OBJECT_ID('dbo.fbIncomingInvoiceItem', 'U') IS NULL THEN 0 ELSE 1 END AS Exists_;
GO
@@ -0,0 +1,63 @@
# InnVoice integration
> Topic `INNV`, prefix `MGFBANKPLUG` — see [`TOPIC_CODES.md`](../../.github/TOPIC_CODES.md).
> Docs index: [`../README.md`](../README.md).
InnVoice is the external invoicing system. Two independent directions share one access:
| Direction | What moves | Entry point | Detail |
|---|---|---|---|
| **Outbound** | our orders / invoices → InnVoice | admin order edit page | this file, *Outbound* |
| **Incoming** | supplier invoices → our own mirror table | admin → *Bejövő számlák* | [`INNVOICE_INCOMING.md`](INNVOICE_INCOMING.md) |
## Files in this topic
| File | Content |
|---|---|
| `README.md` | this overview + the outbound direction |
| [`INNVOICE_INCOMING.md`](INNVOICE_INCOMING.md) | the supplier-invoice mirror and the monthly VAT report built on it |
| [`INNVOICE_ISSUES.md`](INNVOICE_ISSUES.md) | known issues |
| [`INNVOICE_TODO.md`](INNVOICE_TODO.md) | planned work |
| `INNVOICE_incoming_schema.sql` | runbook — creates the mirror tables |
| `INNVOICE_incoming_exchangerate.sql` | runbook — adds `ExchangeRate` to the mirror |
| `INNVOICE_incoming_attachment.sql` | runbook — adds the attachment columns to the mirror |
The three `.sql` files are runbooks: idempotent, re-runnable, `USE FruitBank_DEV` at the top — rewrite the `USE` for PROD.
## Access
Credentials live in `FruitBankSettings` (admin → Bővítmények → FruitBank plugin → Konfigurálás → InnVoice), never in source:
| Setting | Meaning |
|---|---|
| `InnVoiceCompanyName` | the `regnev` path segment |
| `InnVoiceUsername` / `InnVoicePassword` | HTTP Basic auth |
| `InnVoiceBaseUrl` | empty → `https://api.innvoice.hu` |
`InnVoiceCredentials.Require` reads them and **throws naming the missing setting** rather than returning an empty string: an empty username produces a 401, and a 401 reads as a network or password problem, not as an unconfigured plugin.
Request shape: `{baseUrl}/{companyName}/{resource}`.
## Quota
The provider caps requests, and **overrun is not an error** — the response simply carries less data. Both directions spend from the same budget:
- an id-less request: 20 per hour
- the full `incominginvoice` query: 10 per 30 days, after which it silently returns deltas only
Every design decision on the incoming side follows from that second line; see [`INNVOICE_INCOMING.md`](INNVOICE_INCOMING.md).
## Outbound — order submission
The admin **order edit** widget ([`Views/OrderAttributes.cshtml`](../../Views/OrderAttributes.cshtml), rendered by `OrderAttributesViewComponent` into `AdminWidgetZones.OrderDetailsBlock`) hosts the InnVoice card *"Megrendelés beküldése Innvoice-ba"*.
| UI | Endpoint |
|---|---|
| "Megrendelés beküldése" | `InnVoiceOrder/CreateOrder` |
| "Invoice adat ellenőrzése" | `InnVoiceOrder/GetOrderStatus` (also auto-run on page load) |
On success the card shows the InnVoice Order Table ID / Tech ID and a PDF link.
Server side: `InnVoiceApiService` (`Services/`), `InnVoiceOrderController` and `InvoiceController` (`Areas/Admin/Controllers/`).
`OrderAttributes.cshtml` also carries a full invoice-handling JS block with no matching markup — dead, deliberately kept, and it costs one failing request per order page load: [`MGFBANKPLUG-INNV-I-D4W9`](INNVOICE_ISSUES.md). The migration it is groundwork for: [`MGFBANKPLUG-INNV-T-B2N7`](INNVOICE_TODO.md).
+1 -1
View File
@@ -9,7 +9,6 @@ Topic documentation for the FruitBank-specific NopCommerce plugin (Layer 3 — c
- [`SCHEMA.md`](SCHEMA.md) — **Session-start mandatory read; read first for any domain question.** Generated, prose-free full type picture: every entity, DTO, enum, relationship.
- [`DOMAIN_MODEL.md`](DOMAIN_MODEL.md) — Business domain behavior
- [`MEASUREMENT.md`](MEASUREMENT.md) — Weight measurement logic (NetWeight formula, MeasuringStatus lifecycle)
- [`INNVOICE.md`](INNVOICE.md) — InnVoice integration: current order-submission flow, the orphaned invoice JS, and the planned migration to direct draft-invoice submission
- [`CONVENTIONS.md`](CONVENTIONS.md) — naming and XML documentation conventions
- [`PRODUCT_WEIGHT_DATA_DB_VALIDATION.md`](PRODUCT_WEIGHT_DATA_DB_VALIDATION.md) — product weight master-data validation (`AverageWeight`, `Tare`)
@@ -24,6 +23,7 @@ Topic documentation for the FruitBank-specific NopCommerce plugin (Layer 3 — c
- [`STOCK/`](STOCK/README.md) — stock movement: quantity + weight running balance, history and its consistency check (+ `STOCK_ISSUES.md`)
- [`MARGIN/`](MARGIN/README.md) — purchase cost and margin: cost layers, FIFO attribution at order completion, freight/extra distribution, and the report (+ `MARGIN_TODO.md` / `MARGIN_ISSUES.md`, and `MARGIN_schema.sql`)
- [`NOTIFICATION/`](NOTIFICATION/README.md) — customer e-mail notifications: the plugin's own message templates, the token-provider replacement, the `MeasuringStatus`-driven trigger and the repeat-guard flags (+ `NOTIFICATION_ISSUES.md`)
- [`INNVOICE/`](INNVOICE/README.md) — InnVoice integration, both directions: outbound order submission, and the mirrored supplier invoices that feed the monthly VAT report (+ `INNVOICE_INCOMING.md`, `INNVOICE_ISSUES.md` / `INNVOICE_TODO.md`, and three `.sql` runbooks)
- [`PMDATA/`](PMDATA/README.md) — product measurement/logistics master data (`IsMeasurable`, `Tare`, `AverageWeight`, `AverageWeightTreshold`, `CratesPerPallet`): storage, write paths, validation (+ `PMDATA_TODO.md`; the SQL runbook is the flat [`PRODUCT_WEIGHT_DATA_DB_VALIDATION.md`](PRODUCT_WEIGHT_DATA_DB_VALIDATION.md))
## Navigation