3 Commits
Author SHA1 Message Date
Adam f108e46ac4 Az árfolyam forrása a nopCommerce pénznem-nyilvántartása; a SignalR-es dokumentum-út is kitölti
- ShippingCostService: az árfolyam az ICurrencyService-ből (Currency.Rate, EKB-provider), nem az EKÁER fix éves
  árfolyamából — az utóbbi NAV-előírás, ezért a két forrás szándékosan külön marad
- ResolveDocumentCurrencyAsync: a pénznem + árfolyam levezetése egy helyen; belföldinél HUF a fallback,
  külföldinél nincs pótlás (ismeretlen pénznemhez 0 árfolyam tartozik, nem 1)
- FruitBankDataController.AddShippingDocument: eddig egyáltalán nem töltötte ki a két mezőt, ezért az appból
  érkező szállítólevél a DEFAULT (1) értéket kapta
- MARGIN/README: külön szakasz a két árfolyam-forrásról és arról, miért helyes így
- MARGIN_ISSUES T4M6: a régi sorok visszatöltése elvetve (ADR 11. pont — tiszta lappal indulunk), a valódi rés
  a SignalR-út volt; a maradék elfogadott viselkedésként dokumentálva
- MARGIN_TODO: a historikus árfolyam-lekérdezés mint halasztott igény, az indoklással (a kifizetés napja)
2026-08-14 17:55:24 +02:00
Adam 6f23c61830 Az árrés-topik doksijainak átvezetése a landolt állapotra
- MARGIN/README.md: jelen-állapot — az elszámolási egység, a FIFO-hozzárendelés, a számítási képletek, a
  beszerzési érték útja, a költségek és a riport
- MARGIN_TODO: a megépült lépések kipipálva, a máshogy megoldott döntések indoklással; új nyitott pontok
  (leltári leírás mint fogyasztó, előrendelés pontos forrása, SCHEMA.md újragenerálás)
- MARGIN_ISSUES (új): MGFBANKPLUG-MARGIN-B-T4M6 — a fbShippingDocument.FxRate DEFAULT (1) miatt a régi
  devizás beszerzés forintként számol; éles méréssel, az enyhítéssel és a megoldás irányával
- STOCK/README: a rendelés-lezárás készlet-mozgása költség-réteget is fogyaszt
- DOMAIN_MODEL: a két új tábla az entitás-hierarchiában
2026-08-14 16:15:07 +02:00
Adam afb6b61b6a Árrés-kimutatás: költség-rétegek, FIFO-hozzárendelés, szállítmány-költségek és riport (ADR 0002-R7Q2)
- fbCostConsumption és fbShippingCost entitás, repository, DbContext- és DI-bekötés
- CostAttributionService: a kiadott áru FIFO-val a beszerzési rétegekhez rendelve a rendelés lezárásakor
- MarginCalculationService: fajlagos önköltség, raklap-arányos fuvar- és extra-hányad, árrés
- Szállítmány-költségek és Árrés-kimutatás admin felület, menüpontokkal
- A beszerzési érték útja: AI-prompt, kiolvasás-ellenőrző, mentés; az áfakulcs a partner országkódjából
- A dokumentum devizája és árfolyama a meglévő EKÁER-árfolyamforrásból, rögzítéskor befagyasztva
- ADR 0002-R7Q2, a MARGIN és PMDATA topikok, és mindkét ADR horgony-bejegyzése
- README type-location map javítás: az fb* entitások és a konkrét DTO-k a FruitBank.Common-ban vannak
2026-08-14 16:10:36 +02:00
39 changed files with 2665 additions and 14 deletions
+2
View File
@@ -14,3 +14,5 @@ Per-repo topic registry for this plugin, per the **per-repo extension convention
| `EKAER` | EKÁER | Server-side / nopCommerce-specific aspects of NAV EKÁER reporting: VTSZ source from `Product`, `Shipping``tradeCard` data needs, serving the NAV call. The protocol/base layer lives in AyCode.Core (`AyCode.Services/Nav/`). | `docs/EKAER/` |
| `STOCK` | STOCK-MOVEMENT | Készlet-mozgatás és annak nyilvántartása: `Product.StockQuantity` + készlet-súly (`NetWeight` GenericAttribute) írása bevételezéskor, rendelés-lezáráskor és leltár-záráskor, a `StockQuantityHistory` / `StockQuantityHistoryExt` audit-nyom és a futó egyenleg konzisztenciája. | `docs/STOCK/` |
| `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/` |
@@ -1,10 +1,12 @@
using AyCode.Services.Nav.Ekaer;
using DocumentFormat.OpenXml.Vml;
using FruitBank.Common;
using FruitBank.Common.Dtos;
using FruitBank.Common.Entities;
using FruitBank.Common.Helpers;
using FruitBank.Common.Interfaces;
using FruitBank.Common.Services.Ekaer;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -43,6 +45,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly IStoreContext _storeContext;
private readonly PreOrderConversionService _preorderConversionService;
private readonly ShippingCostService _shippingCostService;
public FileManagerController(
IPermissionService permissionService,
@@ -56,8 +59,10 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
FileStorageService fileStorageService,
FruitBankAttributeService fruitBankAttributeService,
IStoreContext storeContext,
PreOrderConversionService preorderConversionService)
PreOrderConversionService preorderConversionService,
ShippingCostService shippingCostService)
{
_shippingCostService = shippingCostService;
_permissionService = permissionService;
_aiApiService = aiApiService;
_cerebrasApiService = cerebrasApiService;
@@ -192,7 +197,9 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
quantityOnDocument = item.QuantityOnDocument,
netWeightOnDocument = item.NetWeightOnDocument,
grossWeightOnDocument = item.GrossWeightOnDocument,
unitPriceOnDocument = item.UnitPriceOnDocument
unitPriceOnDocument = item.UnitPriceOnDocument,
netTotalOnDocument = item.NetTotalOnDocument,
vatRate = item.VatRate
}).ToList(),
extractedText = existingFile.RawText
},
@@ -401,7 +408,9 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
quantityOnDocument = item.QuantityOnDocument,
netWeightOnDocument = item.NetWeightOnDocument,
grossWeightOnDocument = item.GrossWeightOnDocument,
unitPriceOnDocument = item.UnitPriceOnDocument
unitPriceOnDocument = item.UnitPriceOnDocument,
netTotalOnDocument = item.NetTotalOnDocument,
vatRate = item.VatRate
}).ToList(),
extractedText = deserializedContent.extractedData.fullText
},
@@ -647,6 +656,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
GrossWeightOnDocument = deserializedProduct.grossWeight ?? 0,
ProductId = null,
NameOnDocument = deserializedProduct.name,
UnitPriceOnDocument = deserializedProduct.unitCost,
NetTotalOnDocument = ResolveNetTotalOnDocument(deserializedProduct),
// Termék nélkül nincs rekesz/raklap arány, tehát nincs miből kalkulálni — marad a becslés.
Pallets = 1
});
@@ -660,6 +671,29 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
return finalMatchedProducts;
}
/// <summary>Belföldi-e a beszállító — ettől függ, hogy a beszerzés áfás-e.</summary>
/// <remarks>
/// A <c>Partner.CountryCode</c> ISO-2 kód. Hiányzó országkódnál belföldinek vesszük: az áfás eset a
/// „veszélytelen" irány, mert ott az admin beírt kulcsa marad érvényben, míg a téves 0 némán nettónak
/// venne egy bruttó összeget.
/// </remarks>
private static bool IsDomesticPartner(Partner partner)
=> string.IsNullOrWhiteSpace(partner?.CountryCode)
|| partner.CountryCode.Trim().Equals("HU", StringComparison.OrdinalIgnoreCase);
/// <summary>A tétel nettó végösszege: a dokumentumról, hiányában az egységár × mennyiség szorzatból.</summary>
/// <remarks>
/// A fajlagos önköltség ebből áll elő (ADR 0002-R7Q2), ezért nem hagyhatjuk némán 0-n: ha az AI nem talált
/// sor-végösszeget, a szorzat a legjobb rendelkezésre álló érték — ugyanaz, amit az EKÁER értékszámítása is
/// használ (<c>FmEkaerValueCalculator.ItemLineValue</c>). Az admin a kiolvasás-ellenőrzőn felülírhatja.
/// </remarks>
private static decimal ResolveNetTotalOnDocument(ProductReference deserializedProduct)
{
if (deserializedProduct.netTotal is > 0) return (decimal)deserializedProduct.netTotal.Value;
return (decimal)(deserializedProduct.unitCost * (deserializedProduct.quantity ?? 0));
}
private ShippingItem CreateShippingItem(ProductDto productDto, ProductReference deserializedProduct)
{
var shippingItem = new ShippingItem
@@ -674,6 +708,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
NameOnDocument = deserializedProduct.name,
IsMeasurable = productDto.IsMeasurable,
UnitPriceOnDocument = deserializedProduct.unitCost,
NetTotalOnDocument = ResolveNetTotalOnDocument(deserializedProduct),
// A kiolvasás-ellenőrző felület innen veszi a rekesz/raklap arányt: enélkül nem tudna kalkulálni,
// és azt sem venné észre, hogy a terméken hiányzik (ADR 0004-M9F3).
ProductDto = productDto
@@ -1044,11 +1079,20 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
var partner = await _dbContext.Partners.GetByIdAsync(request.PartnerId.Value);
// Create the shipping document entity
// A beszerzés devizája a beszállítóé; egy beszerzés = egy deviza = egy árfolyam (ADR 0002-R7Q2).
var isDomesticPartner = IsDomesticPartner(partner);
var (documentCurrency, documentFxRate) = await _shippingCostService.ResolveDocumentCurrencyAsync(partner);
var shippingDocument = new ShippingDocument
{
DocumentIdNumber = request.DocumentIdNumber,
PartnerId = request.PartnerId.Value,
Country = partner?.Country,
CurrencyCode = documentCurrency,
// A beérkezéskori árfolyam befagyasztva (ADR 0002-R7Q2). A forrás a nopCommerce pénznem-
// nyilvántartása, NEM az EKÁER fix éves árfolyama — lásd ShippingCostService.ResolveFxRateAsync.
FxRate = documentFxRate,
TotalPallets = request.TotalPallets,
PdfFileName = originalFile.FileName,
ShippingDate = DateTime.UtcNow,
@@ -1100,6 +1144,14 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
NetWeightOnDocument = netWeightOnDocument,
GrossWeightOnDocument = grossWeightOnDocument,
UnitPriceOnDocument = itemDto.UnitPriceOnDocument,
// A fajlagos önköltség horgonya (ADR 0002-R7Q2). Ha a felületről 0 érkezik, az egységár ×
// mennyiség szorzat a tartalék — némán 0-n hagyva az egész tétel árrése elveszne.
NetTotalOnDocument = itemDto.NetTotalOnDocument > 0
? itemDto.NetTotalOnDocument
: (decimal)(itemDto.UnitPriceOnDocument * itemDto.QuantityOnDocument),
// Közösségi beszerzésnél a számla áfa nélkül jön — ez az országkódból egzakt, nem becslés.
// Belföldinél a kulcs a termék adókategóriájának kérdése, ezért marad az admin beírt értéke.
VatRate = isDomesticPartner ? itemDto.VatRate : 0m,
IsMeasurable = itemDto.IsMeasurable,
ProductDto = productDto
};
@@ -1241,6 +1293,14 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
public double? grossWeight { get; set; }
public int? productId { get; set; }
public double unitCost { get; set; }
/// <summary>A tétel NETTÓ végösszege a dokumentumon — a fajlagos önköltség horgonya (ADR 0002-R7Q2).</summary>
/// <remarks>
/// Az áfakulcs SZÁNDÉKOSAN nem az AI-tól jön: a beszállító országkódjából levezethető (közösségi beszerzés
/// → 0), a belföldi kulcs pedig a termék adókategóriájának kérdése, nem a papír leolvasásáé. Egy félreolvasott
/// százalék némán torzítaná a nettósítást, ráadásul két, egymásnak ellentmondó forrást teremtene.
/// </remarks>
public double? netTotal { get; set; }
}
public class ProductReferenceResponse
@@ -1277,6 +1337,13 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
public double NetWeightOnDocument { get; set; }
public double GrossWeightOnDocument { get; set; }
public double UnitPriceOnDocument { get; set; }
/// <summary>A tétel NETTÓ végösszege a dokumentumon (ADR 0002-R7Q2). 0-án érkezve a szerver az egységár ×
/// mennyiség szorzatot használja.</summary>
public decimal NetTotalOnDocument { get; set; }
/// <summary>A beszerzés áfakulcsa százalékban (27.00 = 27%); közösségi beszerzésnél 0.</summary>
public decimal VatRate { get; set; }
public bool IsMeasurable { get; set; }
}
@@ -0,0 +1,205 @@
using LinqToDB;
using Microsoft.AspNetCore.Mvc;
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>Árrés-kimutatás szállítólevél, kamion és hónap bontásban (ADR 0002-R7Q2).</summary>
[Area(AreaNames.ADMIN)]
[AuthorizeAdmin]
public class MarginReportController(
IPermissionService permissionService,
MarginCalculationService marginCalculationService,
FruitBankDbContext dbContext)
: BaseAdminController
{
public async Task<IActionResult> Index()
{
if (!await permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/MarginReport/Index.cshtml");
}
/// <summary>Az időszak árrése, a kért bontásban. Az időszak az ELADÁS dátuma szerint szűr.</summary>
[HttpGet]
public async Task<IActionResult> GetReport(DateTime? from, DateTime? to, string groupBy = "document")
{
if (!await permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
var fromDate = (from ?? new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1)).Date;
var toDate = (to ?? DateTime.UtcNow.Date).Date.AddDays(1);
var rows = await marginCalculationService.CalculateForPeriodAsync(fromDate, toDate);
var summary = new
{
revenue = rows.Sum(r => r.RevenueHuf),
goodsCost = rows.Sum(r => r.GoodsCostHuf),
freight = rows.Sum(r => r.FreightShareHuf),
extra = rows.Sum(r => r.ExtraShareHuf),
margin = rows.Sum(r => r.MarginHuf),
unknownSourceRevenue = rows.Where(r => r.IsUnknownSource).Sum(r => r.RevenueHuf),
missingPurchaseValueRevenue = rows
.Where(r => !r.IsUnknownSource && r.Sold > 0 && r.GoodsCostHuf == 0m)
.Sum(r => r.RevenueHuf),
untrustworthyFxRevenue = rows.Where(r => r.HasUntrustworthyFxRate).Sum(r => r.RevenueHuf),
rowCount = rows.Count
};
var groups = groupBy switch
{
"truck" => await GroupByTruckAsync(rows),
"month" => GroupByMonth(rows),
_ => await GroupByDocumentAsync(rows)
};
return Json(new { success = true, summary, groups });
}
private async Task<object> GroupByDocumentAsync(List<MarginRow> rows)
{
var documentIds = rows.Where(r => r.ShippingDocumentId.HasValue)
.Select(r => r.ShippingDocumentId!.Value).Distinct().ToList();
var documents = (await dbContext.ShippingDocuments.GetAll()
.Where(d => documentIds.Contains(d.Id))
.Select(d => new
{
d.Id,
d.DocumentIdNumber,
d.ShippingDate,
partner = d.Partner != null ? d.Partner.Name : null
})
.ToListAsync())
.ToDictionary(d => d.Id);
return rows
.GroupBy(r => r.ShippingDocumentId)
.Select(g =>
{
var document = g.Key.HasValue && documents.ContainsKey(g.Key.Value) ? documents[g.Key.Value] : null;
return BuildGroup(
g.Key.HasValue ? $"{document?.DocumentIdNumber ?? "#" + g.Key.Value}" : "Ismeretlen forrású",
document?.partner,
document?.ShippingDate,
g.ToList());
})
.OrderByDescending(g => g.margin)
.ToList();
}
/// <summary>Kamion-bontás: a tétel árrése a raklap-arány szerint oszlik a kamionjai között.</summary>
private async Task<object> GroupByTruckAsync(List<MarginRow> rows)
{
var truckIds = rows.SelectMany(r => r.TruckShares).Select(s => s.ShippingId).Distinct().ToList();
var trucks = (await dbContext.Shippings.GetAll()
.Where(s => truckIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.ShippingDate,
s.LicencePlate,
carrier = s.CargoPartner != null ? s.CargoPartner.Name : s.CargoCompany
})
.ToListAsync())
.ToDictionary(s => s.Id);
// Minden sor annyiszor jelenik meg, ahány kamionra bomlott — a számai a hányaddal szorozva.
var split = rows.SelectMany(r => r.TruckShares.Count > 0
? r.TruckShares.Select(s => (ShippingId: (int?)s.ShippingId, Row: Scale(r, (decimal)s.Ratio)))
: [((int?)null, r)]);
return split
.GroupBy(x => x.ShippingId)
.Select(g =>
{
var truck = g.Key.HasValue && trucks.ContainsKey(g.Key.Value) ? trucks[g.Key.Value] : null;
return BuildGroup(
truck != null ? (truck.LicencePlate ?? $"#{truck.Id}") : "Nincs kamionhoz rendelve",
truck?.carrier,
truck?.ShippingDate,
g.Select(x => x.Row).ToList());
})
.OrderByDescending(g => g.margin)
.ToList();
}
/// <summary>Havi bontás az ELADÁS hónapja szerint — a sor a fogyasztás dátumából hozza a periódust.</summary>
private static object GroupByMonth(List<MarginRow> rows)
=> rows
.GroupBy(r => r.Period)
.OrderByDescending(g => g.Key)
.Select(g => BuildGroup(g.First().PeriodLabel, null, null, g.ToList()))
.ToList();
private static MarginRow Scale(MarginRow row, decimal ratio)
=> row with
{
Received = row.Received * (double)ratio,
Sold = row.Sold * (double)ratio,
Remaining = row.Remaining * (double)ratio,
GoodsCostHuf = row.GoodsCostHuf * ratio,
FreightShareHuf = row.FreightShareHuf * ratio,
ExtraShareHuf = row.ExtraShareHuf * ratio,
RevenueHuf = row.RevenueHuf * ratio,
MarginHuf = row.MarginHuf * ratio
};
private static dynamic BuildGroup(string title, string subtitle, DateTime? date, List<MarginRow> rows)
{
var revenue = rows.Sum(r => r.RevenueHuf);
var margin = rows.Sum(r => r.MarginHuf);
return new
{
title,
subtitle,
date,
revenue,
goodsCost = rows.Sum(r => r.GoodsCostHuf),
freight = rows.Sum(r => r.FreightShareHuf),
extra = rows.Sum(r => r.ExtraShareHuf),
margin,
marginPercent = revenue > 0 ? Math.Round(margin / revenue * 100m, 2) : 0m,
hasUnknownSource = rows.Any(r => r.IsUnknownSource),
items = rows.Select(r => new
{
productName = r.ProductName,
unit = r.IsMeasurable ? "kg" : "rekesz",
// A dokumentum a soron is ott van, nem csak a csoport fejlécében: kamion- és havi bontásban
// enélkül nem derülne ki, MELYIK szállítólevélen kell pótolni a hiányzó beszerzési értéket.
documentNumber = r.DocumentNumber,
documentDate = r.DocumentDate,
hasNoTruckAllocation = r.HasNoTruckAllocation,
hasUntrustworthyFxRate = r.HasUntrustworthyFxRate,
documentCurrency = r.DocumentCurrency,
documentFxRate = r.DocumentFxRate,
// A neveket KIÍRJUK kisbetűvel: a rövidített alak (r.Sold) nagybetűs mezőt ad, amit a nézet
// `undefined`-ként olvasna, és némán 0-t mutatna egy nem nulla érték helyett.
received = r.Received,
sold = r.Sold,
remaining = r.Remaining,
// Régi, a feature előtt keletkezett szállítólevélnél nincs beszerzési érték: az önköltség 0, és
// az árrés 100%-nak látszana. Ezt jelölni kell, nem szép számként megmutatni.
missingPurchaseValue = !r.IsUnknownSource && r.Sold > 0 && r.GoodsCostHuf == 0m,
goodsCost = r.GoodsCostHuf,
freight = r.FreightShareHuf,
extra = r.ExtraShareHuf,
revenue = r.RevenueHuf,
margin = r.MarginHuf,
marginPercent = r.MarginPercent,
isUnknownSource = r.IsUnknownSource
})
};
}
}
@@ -0,0 +1,151 @@
using LinqToDB;
using Microsoft.AspNetCore.Mvc;
using Nop.Core;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
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>Fuvardíj és egyéb szállítmány-költségek rögzítése (ADR 0002-R7Q2).</summary>
/// <remarks>
/// A költség tulajdonosa a kamion (fuvardíj) vagy a szállítólevél (vám, rakodás) — a felület mindkettőt kezeli,
/// mert egy kamionon több beszállító áruja is lehet, és a vám jellemzően az egyikhez tartozik.
/// </remarks>
[Area(AreaNames.ADMIN)]
[AuthorizeAdmin]
public class ShippingCostController(
IPermissionService permissionService,
IWorkContext workContext,
FruitBankDbContext dbContext,
ShippingCostService shippingCostService)
: BaseAdminController
{
/// <summary>Hány napra visszamenőleg listázunk alapból — a költségszámla jellemzően hetekkel később érkezik.</summary>
private const int DefaultDayWindow = 60;
public async Task<IActionResult> Index()
{
if (!await permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/ShippingCost/Index.cshtml");
}
/// <summary>A kamionok az időablakban, dokumentumaikkal és mindkét szint költségeivel.</summary>
[HttpGet]
public async Task<IActionResult> GetShipments(DateTime? from, DateTime? to)
{
if (!await permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
var fromDate = from ?? DateTime.UtcNow.Date.AddDays(-DefaultDayWindow);
var toDate = (to ?? DateTime.UtcNow.Date).AddDays(1);
var shipments = await dbContext.Shippings.GetAll()
.Where(s => s.ShippingDate >= fromDate && s.ShippingDate < toDate)
.OrderByDescending(s => s.ShippingDate)
.Select(s => new
{
s.Id,
s.ShippingDate,
s.LicencePlate,
s.IsClosed,
carrier = s.CargoPartner != null ? s.CargoPartner.Name : s.CargoCompany,
carrierCurrency = s.CargoPartner != null ? s.CargoPartner.Currency : null
})
.ToListAsync();
var shipmentIds = shipments.Select(s => s.Id).ToList();
var documents = await dbContext.ShippingDocuments.GetAll()
.Where(d => d.ShippingId != null && shipmentIds.Contains(d.ShippingId.Value))
.Select(d => new
{
d.Id,
d.ShippingId,
d.DocumentIdNumber,
d.CurrencyCode,
d.FxRate,
partner = d.Partner != null ? d.Partner.Name : null
})
.ToListAsync();
var documentIds = documents.Select(d => d.Id).ToList();
var costRows = await dbContext.ShippingCosts.GetAll()
.Where(c => (c.ShippingId != null && shipmentIds.Contains(c.ShippingId.Value))
|| (c.ShippingDocumentId != null && documentIds.Contains(c.ShippingDocumentId.Value)))
.ToListAsync();
// A mezőneveket KIÍRJUK: a nézet ezeket a neveket használja, és a szerializáló betűzési szabályára
// hagyatkozva egy elgépelt nagybetű némán `undefined`-ot adna a felületen.
object ToCostJson(ShippingCost c) => new
{
id = c.Id,
costTypeId = c.CostTypeId,
name = c.Name,
netAmount = c.NetAmount,
currencyCode = c.CurrencyCode,
fxRate = c.FxRate,
vatRate = c.VatRate
};
return Json(new
{
success = true,
shipments = shipments.Select(s => new
{
id = s.Id,
shippingDate = s.ShippingDate,
licencePlate = s.LicencePlate,
isClosed = s.IsClosed,
carrier = s.carrier,
carrierCurrency = s.carrierCurrency,
costs = costRows.Where(c => c.ShippingId == s.Id).Select(ToCostJson),
documents = documents.Where(d => d.ShippingId == s.Id).Select(d => new
{
id = d.Id,
documentIdNumber = d.DocumentIdNumber,
currencyCode = d.CurrencyCode,
fxRate = d.FxRate,
partner = d.partner,
costs = costRows.Where(c => c.ShippingDocumentId == d.Id).Select(ToCostJson)
})
})
});
}
[HttpPost]
public async Task<IActionResult> SaveCost(int id, int? shippingId, int? shippingDocumentId, int costTypeId,
string name, decimal netAmount, string currencyCode, decimal vatRate)
{
if (!await permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
var customer = await workContext.GetCurrentCustomerAsync();
var saved = await shippingCostService.SaveAsync(id, shippingId, shippingDocumentId,
(ShippingCostType)costTypeId, name, netAmount, currencyCode, vatRate, customer?.Id);
if (saved == null)
return Json(new { success = false, message = "A költség pontosan egy tulajdonoshoz (kamion VAGY szállítólevél) tartozhat, és az összeg nem lehet negatív." });
return Json(new { success = true, id = saved.Id });
}
[HttpPost]
public async Task<IActionResult> DeleteCost(int id)
{
if (!await permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
var deleted = await shippingCostService.DeleteAsync(id);
return Json(new { success = deleted });
}
}
@@ -722,6 +722,18 @@
<label>Egységár</label>
<input type="number" step="0.01" class="form-control form-control-sm" value="${item.unitPriceOnDocument ? item.unitPriceOnDocument.toFixed(2) : '0.00'}" data-field="unitPriceOnDocument" data-index="${index}">
</div>
<div>
<label>Nettó tétel-összeg <small class="text-muted">(ebből lesz az árrés)</small></label>
<input type="number" step="0.01" min="0" class="form-control form-control-sm ${(item.netTotalOnDocument || 0) > 0 ? '' : 'border-warning'}"
value="${item.netTotalOnDocument ? item.netTotalOnDocument.toFixed(2) : '0.00'}" data-field="netTotalOnDocument" data-index="${index}"
title="A tétel NETTÓ végösszege a dokumentumon. Üresen hagyva az egységár × mennyiség szorzat lép be. Az árrés-számítás ezt az értéket használja.">
</div>
<div>
<label>Áfa % <small class="text-muted">(közösségi beszerzésnél 0)</small></label>
<input type="number" step="0.01" min="0" class="form-control form-control-sm"
value="${item.vatRate ? item.vatRate.toFixed(2) : '0.00'}" data-field="vatRate" data-index="${index}"
title="A beszerzés áfakulcsa. A nettó tétel-összeg MINDIG nettó — ez a kulcs csak a későbbi áfa-analitikához kell.">
</div>
</div>
`;
@@ -737,11 +749,24 @@
} else if (field === 'palletGroupNo') {
// Üres = nincs csoportban. A 0 nem csoport-azonosító, azt is null-ra visszük.
value = value ? (parseInt(value) || null) : null;
} else if (['netWeightOnDocument', 'grossWeightOnDocument', 'unitPriceOnDocument', 'pallets'].includes(field)) {
} else if (['netWeightOnDocument', 'grossWeightOnDocument', 'unitPriceOnDocument', 'pallets',
'netTotalOnDocument', 'vatRate'].includes(field)) {
value = value ? parseFloat(value) : 0;
}
shippingItems[idx][field] = value;
// A nettó tétel-összeg a fajlagos önköltség horgonya (ADR 0002-R7Q2). Ha még nincs kitöltve,
// az egységár × mennyiség szorzatból előálljon MENTÉS ELŐTT — ugyanaz a tartalék, mint a szerveren,
// csak itt az admin látja is, és felülírhatja. A már beírt értéket nem bántjuk.
if (['unitPriceOnDocument', 'quantityOnDocument'].includes(field)
&& !(shippingItems[idx].netTotalOnDocument > 0)) {
const unitPrice = shippingItems[idx].unitPriceOnDocument || 0;
const quantity = shippingItems[idx].quantityOnDocument || 0;
if (unitPrice > 0 && quantity > 0)
shippingItems[idx].netTotalOnDocument = Math.round(unitPrice * quantity * 100) / 100;
}
if (field === 'palletsOnDocument') updateTotalPallets();
// A raklapigény a rekeszszám függvénye (ADR 0004-M9F3): mennyiség-változáskor újraszámol, akkor is,
@@ -811,7 +836,8 @@
name: '', hungarianName: '', nameOnDocument: '', productId: null,
palletsOnDocument: 0, pallets: 0, palletGroupNo: null, cratesPerPallet: 0,
quantityOnDocument: 0, netWeightOnDocument: 0,
grossWeightOnDocument: 0, unitPriceOnDocument: 0
grossWeightOnDocument: 0, unitPriceOnDocument: 0,
netTotalOnDocument: 0, vatRate: 0
});
renderShippingItems();
}
@@ -0,0 +1,203 @@
@using Nop.Web.Framework.Infrastructure
@{
ViewBag.PageTitle = "Árrés-kimutatás";
NopHtml.SetActiveMenuItemSystemName("FruitBank");
}
<div class="content-header clearfix">
<h1 class="float-left">Árrés-kimutatás</h1>
</div>
<section class="content">
<div class="container-fluid">
@* ── Filter ─────────────────────────────────────────────────── *@
<div class="card card-default card-search mb-2">
<div class="card-body py-2">
<div class="row align-items-end">
<div class="col-md-3">
<div class="form-group mb-1">
<label class="col-form-label">Eladás — ettől</label>
<input type="date" id="fb-m-from" class="form-control" />
</div>
</div>
<div class="col-md-3">
<div class="form-group mb-1">
<label class="col-form-label">Eladás — eddig</label>
<input type="date" id="fb-m-to" class="form-control" />
</div>
</div>
<div class="col-md-3">
<div class="form-group mb-1">
<label class="col-form-label">Bontás</label>
<select id="fb-m-groupby" class="form-control">
<option value="document">Szállítólevél szerint</option>
<option value="truck">Kamion szerint</option>
<option value="month">Időszak összesen</option>
</select>
</div>
</div>
<div class="col-md-3">
<div class="form-group mb-1">
<label class="col-form-label">&nbsp;</label>
<button type="button" id="fb-m-load" class="btn btn-primary btn-block">
<i class="fas fa-calculator"></i> Számítás
</button>
</div>
</div>
</div>
<small class="text-muted">
Az időszak az <strong>eladás</strong> dátumára szűr (a rendelés lezárása), nem a beérkezésére.
</small>
</div>
</div>
<div id="fb-m-summary" class="row mb-2"></div>
<div id="fb-m-warning"></div>
<div id="fb-m-groups"></div>
<div id="fb-m-empty" class="alert alert-info d-none">Nincs adat az időszakban.</div>
</div>
</section>
<script>
const huf = n => (n ?? 0).toLocaleString('hu-HU', { maximumFractionDigits: 0 }) + ' Ft';
const qty = n => (n ?? 0).toLocaleString('hu-HU', { maximumFractionDigits: 1 });
const pct = n => (n ?? 0).toLocaleString('hu-HU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + '%';
document.addEventListener('DOMContentLoaded', () => {
const now = new Date();
document.getElementById('fb-m-from').value = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
document.getElementById('fb-m-to').value = now.toISOString().slice(0, 10);
document.getElementById('fb-m-load').addEventListener('click', load);
load();
});
async function load() {
const from = document.getElementById('fb-m-from').value;
const to = document.getElementById('fb-m-to').value;
const groupBy = document.getElementById('fb-m-groupby').value;
const res = await fetch(`/Admin/MarginReport/GetReport?from=${from}&to=${to}&groupBy=${groupBy}`);
const data = await res.json();
renderSummary(data.summary);
renderGroups(data.groups || []);
}
function renderSummary(s) {
const tile = (label, value, cls) => `
<div class="col-md-2 col-sm-4">
<div class="card card-default mb-2">
<div class="card-body py-2 text-center">
<div class="text-muted small">${label}</div>
<div class="h5 mb-0 ${cls || ''}">${value}</div>
</div>
</div>
</div>`;
const marginCls = (s?.margin ?? 0) >= 0 ? 'text-success' : 'text-danger';
document.getElementById('fb-m-summary').innerHTML =
tile('Árbevétel', huf(s?.revenue)) +
tile('Áru-önköltség', huf(s?.goodsCost)) +
tile('Fuvar', huf(s?.freight)) +
tile('Egyéb költség', huf(s?.extra)) +
tile('Árrés', huf(s?.margin), marginCls) +
tile('Árrés %', pct(s?.revenue > 0 ? (s.margin / s.revenue * 100) : 0), marginCls);
const warn = document.getElementById('fb-m-warning');
let warnings = '';
if (s?.unknownSourceRevenue > 0)
warnings += `<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
<strong>${huf(s.unknownSourceRevenue)}</strong> árbevétel ismeretlen forrású árun keletkezett —
ehhez nem tartozik beszerzés, ezért az önköltsége 0, és az árrést felfelé torzítja.
Ez az indulás előtti készlet kifutásáig normális.
</div>`;
if (s?.missingPurchaseValueRevenue > 0)
warnings += `<div class="alert alert-danger">
<i class="fas fa-exclamation-circle"></i>
<strong>${huf(s.missingPurchaseValueRevenue)}</strong> árbevétel olyan szállítólevélről származik,
amelyen <strong>nincs beszerzési érték</strong> (a tétel <code>NetTotalOnDocument</code> mezője 0).
Ezeknél az önköltség 0, tehát az árrés 100%-nak látszik — ez NEM valós nyereség.
A funkció előtt keletkezett dokumentumoknál ez várható; utólag a szállítólevél tételén pótolható.
</div>`;
if (s?.untrustworthyFxRevenue > 0)
warnings += `<div class="alert alert-danger">
<i class="fas fa-exclamation-circle"></i>
<strong>${huf(s.untrustworthyFxRevenue)}</strong> árbevételnél a szállítólevél
<strong>árfolyama nem megbízható</strong> (hiányzó pénznem, vagy deviza 1-es árfolyammal).
Ilyenkor a devizás beszerzést forintként vettük, tehát az önköltség a valós töredéke, az árrés
pedig irreálisan magas. A régi, a funkció előtti dokumentumoknál ez rendszerszerű.
</div>`;
warn.innerHTML = warnings;
}
function renderGroups(groups) {
document.getElementById('fb-m-empty').classList.toggle('d-none', groups.length > 0);
document.getElementById('fb-m-groups').innerHTML = groups.map(g => `
<div class="card card-default mb-2">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong>${g.title}</strong>
${g.subtitle ? `<small class="text-muted ml-2">${g.subtitle}</small>` : ''}
${g.date ? `<small class="text-muted ml-2">${new Date(g.date).toLocaleDateString('hu-HU')}</small>` : ''}
${g.hasUnknownSource ? '<span class="badge badge-warning ml-2">ismeretlen forrás</span>' : ''}
</div>
<div>
<span class="text-muted mr-3">Bevétel: ${huf(g.revenue)}</span>
<strong class="${g.margin >= 0 ? 'text-success' : 'text-danger'}">
Árrés: ${huf(g.margin)} (${pct(g.marginPercent)})
</strong>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Termék</th>
<th class="text-right">Eladva</th>
<th class="text-right">Maradt</th>
<th class="text-right">Árbevétel</th>
<th class="text-right">Önköltség</th>
<th class="text-right">Fuvar</th>
<th class="text-right">Egyéb</th>
<th class="text-right">Árrés</th>
<th class="text-right">%</th>
</tr>
</thead>
<tbody>
${g.items.map(i => `
<tr class="${i.isUnknownSource ? 'table-warning' : (i.missingPurchaseValue ? 'table-danger' : '')}">
<td>
${i.productName || ''}
${i.documentNumber ? `<small class="text-muted ml-1">${i.documentNumber}${i.documentDate ? ' · ' + new Date(i.documentDate).toLocaleDateString('hu-HU') : ''}</small>` : ''}
${i.missingPurchaseValue ? '<i class="fas fa-exclamation-circle text-danger ml-1" title="Nincs beszerzési érték EZEN a szállítólevél-tételen — az önköltség 0. A FIFO a legrégebbi réteget fogyasztja, tehát itt kell pótolni, nem egy újabb szállítólevélen."></i>' : ''}
${i.hasNoTruckAllocation ? '<i class="fas fa-truck text-warning ml-1" title="A tétel nincs kamionhoz rendelve, ezért nem kaphat fuvar-hányadot — a 0 itt adathiány, nem valós nulla."></i>' : ''}
${i.hasUntrustworthyFxRate ? `<i class="fas fa-euro-sign text-danger ml-1" title="Nem megbízható árfolyam: pénznem=${i.documentCurrency || '—'}, árfolyam=${i.documentFxRate}. A devizás beszerzés forintként számolódott, az önköltség a valós töredéke."></i>` : ''}
</td>
<td class="text-right">${qty(i.sold)} ${i.unit}</td>
<td class="text-right">${i.isUnknownSource ? '—' : qty(i.remaining) + ' ' + i.unit}</td>
<td class="text-right">${huf(i.revenue)}</td>
<td class="text-right">${huf(i.goodsCost)}</td>
<td class="text-right">${huf(i.freight)}</td>
<td class="text-right">${huf(i.extra)}</td>
<td class="text-right ${i.margin >= 0 ? 'text-success' : 'text-danger'}">${huf(i.margin)}</td>
<td class="text-right">${pct(i.marginPercent)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`).join('');
}
</script>
@@ -0,0 +1,236 @@
@using Nop.Web.Framework.Infrastructure
@{
ViewBag.PageTitle = "Szállítmány-költségek";
NopHtml.SetActiveMenuItemSystemName("FruitBank");
}
@* A nop admin minden POST-ot antiforgery-tokennel véd — enélkül a mentés 400-at ad. *@
@Html.AntiForgeryToken()
@* ── Header ─────────────────────────────────────────────────────── *@
<div class="content-header clearfix">
<h1 class="float-left">Szállítmány-költségek</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 <strong>fuvardíj a kamionhoz</strong>, a vám és a rakodás jellemzően a <strong>szállítólevélhez</strong>
tartozik. Az összeg mindig <strong>nettó</strong>, a saját devizájában — az árfolyam a rögzítés
pillanatában rögzül. A költség raklap-arányosan oszlik szét az árun.
</p>
</div>
</div>
@* ── Filter panel ───────────────────────────────────────────── *@
<div class="card card-default card-search mb-2">
<div class="card-body py-2">
<div class="row align-items-end">
<div class="col-md-3">
<div class="form-group mb-1">
<label class="col-form-label">Ettől</label>
<input type="date" id="fb-cost-from" class="form-control" />
</div>
</div>
<div class="col-md-3">
<div class="form-group mb-1">
<label class="col-form-label">Eddig</label>
<input type="date" id="fb-cost-to" class="form-control" />
</div>
</div>
<div class="col-md-3">
<div class="form-group mb-1">
<label class="col-form-label">&nbsp;</label>
<button type="button" id="fb-cost-search-btn" class="btn btn-primary btn-block">
<i class="fas fa-search"></i> Betöltés
</button>
</div>
</div>
</div>
</div>
</div>
<div id="fb-cost-list"></div>
<div id="fb-cost-empty" class="alert alert-info d-none">Nincs szállítmány a megadott időszakban.</div>
</div>
</section>
<script>
const COST_TYPES = [
{ id: 10, name: 'Fuvardíj' },
{ id: 20, name: 'Vám' },
{ id: 30, name: 'Rakodás' },
{ id: 99, name: 'Egyéb' }
];
const costTypeName = id => (COST_TYPES.find(t => t.id === id) || { name: '?' }).name;
const fmt = n => (n ?? 0).toLocaleString('hu-HU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
let shipments = [];
const antiForgeryToken = () => document.querySelector('input[name="__RequestVerificationToken"]').value;
document.addEventListener('DOMContentLoaded', () => {
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 60);
document.getElementById('fb-cost-from').value = from.toISOString().slice(0, 10);
document.getElementById('fb-cost-to').value = to.toISOString().slice(0, 10);
document.getElementById('fb-cost-search-btn').addEventListener('click', loadShipments);
loadShipments();
});
async function loadShipments() {
const from = document.getElementById('fb-cost-from').value;
const to = document.getElementById('fb-cost-to').value;
const res = await fetch(`/Admin/ShippingCost/GetShipments?from=${from}&to=${to}`);
const data = await res.json();
shipments = data.shipments || [];
render();
}
function render() {
const list = document.getElementById('fb-cost-list');
document.getElementById('fb-cost-empty').classList.toggle('d-none', shipments.length > 0);
list.innerHTML = shipments.map(s => `
<div class="card card-default mb-2">
<div class="card-header">
<strong>${new Date(s.shippingDate).toLocaleDateString('hu-HU')}</strong>
&nbsp;·&nbsp; ${s.licencePlate || '<em class="text-muted">nincs rendszám</em>'}
&nbsp;·&nbsp; ${s.carrier || '<em class="text-muted">nincs fuvarozó</em>'}
${s.isClosed ? '<span class="badge badge-secondary ml-2">lezárt</span>' : ''}
</div>
<div class="card-body">
<h6>Kamion-szintű költségek <small class="text-muted">(fuvardíj)</small></h6>
${renderCostTable(s.costs, { shippingId: s.id }, s.carrierCurrency || 'HUF')}
${(s.documents || []).map(d => `
<hr />
<h6>
Szállítólevél: ${d.documentIdNumber || '<em class="text-muted">nincs szám</em>'}
<small class="text-muted">${d.partner || ''} · ${d.currencyCode || 'HUF'}</small>
</h6>
${renderCostTable(d.costs, { shippingDocumentId: d.id }, d.currencyCode || 'HUF')}
`).join('')}
</div>
</div>
`).join('');
}
function renderCostTable(costs, owner, defaultCurrency) {
const ownerAttr = owner.shippingId
? `data-shipping-id="${owner.shippingId}"`
: `data-document-id="${owner.shippingDocumentId}"`;
const rows = (costs || []).map(c => `
<tr>
<td>${costTypeName(c.costTypeId)}</td>
<td>${c.name || ''}</td>
<td class="text-right">${fmt(c.netAmount)} ${c.currencyCode || ''}</td>
<td class="text-right">${c.fxRate > 0 ? fmt(c.fxRate) : '<span class="text-danger" title="Nincs konfigurált árfolyam">nincs árfolyam</span>'}</td>
<td class="text-right">${c.fxRate > 0 ? fmt(c.netAmount * c.fxRate) + ' HUF' : '—'}</td>
<td class="text-right">
<button type="button" class="btn btn-sm btn-danger" onclick="deleteCost(${c.id})">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`).join('');
return `
<table class="table table-sm table-bordered mb-1">
<thead>
<tr>
<th style="width:15%">Típus</th>
<th>Megnevezés</th>
<th style="width:18%" class="text-right">Nettó összeg</th>
<th style="width:12%" class="text-right">Árfolyam</th>
<th style="width:15%" class="text-right">HUF</th>
<th style="width:8%"></th>
</tr>
</thead>
<tbody>
${rows || '<tr><td colspan="6" class="text-muted text-center">Nincs rögzített költség.</td></tr>'}
<tr class="bg-light" ${ownerAttr}>
<td>
<select class="form-control form-control-sm fb-new-type">
${COST_TYPES.map(t => `<option value="${t.id}">${t.name}</option>`).join('')}
</select>
</td>
<td><input type="text" class="form-control form-control-sm fb-new-name" placeholder="Megnevezés" /></td>
<td><input type="number" step="0.01" min="0" class="form-control form-control-sm fb-new-amount" placeholder="0.00" /></td>
<td><input type="text" class="form-control form-control-sm fb-new-currency" value="${defaultCurrency}" /></td>
<td><input type="number" step="0.01" min="0" class="form-control form-control-sm fb-new-vat" placeholder="Áfa %" /></td>
<td class="text-right">
<button type="button" class="btn btn-sm btn-primary" onclick="saveCost(this)">
<i class="fas fa-plus"></i>
</button>
</td>
</tr>
</tbody>
</table>
`;
}
async function saveCost(btn) {
const row = btn.closest('tr');
const shippingId = row.dataset.shippingId || null;
const documentId = row.dataset.documentId || null;
const amount = parseFloat(row.querySelector('.fb-new-amount').value || '0');
if (!(amount > 0)) { alert('Adj meg egy nullánál nagyobb nettó összeget.'); return; }
const body = new URLSearchParams({
id: '0',
costTypeId: row.querySelector('.fb-new-type').value,
name: row.querySelector('.fb-new-name').value || '',
netAmount: amount,
currencyCode: row.querySelector('.fb-new-currency').value || '',
vatRate: parseFloat(row.querySelector('.fb-new-vat').value || '0')
});
if (shippingId) body.append('shippingId', shippingId);
if (documentId) body.append('shippingDocumentId', documentId);
const res = await fetch('/Admin/ShippingCost/SaveCost', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'RequestVerificationToken': antiForgeryToken()
},
body
});
const data = await res.json();
if (!data.success) { alert(data.message || 'A mentés nem sikerült.'); return; }
await loadShipments();
}
async function deleteCost(id) {
if (!confirm('Biztosan törlöd ezt a költséget?')) return;
const res = await fetch('/Admin/ShippingCost/DeleteCost', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'RequestVerificationToken': antiForgeryToken()
},
body: new URLSearchParams({ id })
});
const data = await res.json();
if (!data.success) { alert('A törlés nem sikerült.'); return; }
await loadShipments();
}
</script>
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@@ -51,6 +51,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
ShippingCleanupService shippingCleanupService,
IFruitBankEkaerService fruitBankEkaerService,
IEkaerSettings ekaerSettings,
ShippingCostService shippingCostService,
IEnumerable<IAcLogWriterBase> logWriters)
: BasePluginController, IFruitBankDataControllerServer
{
@@ -1348,6 +1349,18 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
_logger.Detail($"AddShippingDocument invoked; id: {shippingDocument.Id}");
// A pénznem és a beérkezéskori árfolyam a beszállítóból (ADR 0002-R7Q2). Enélkül az appból érkező
// szállítólevél az oszlop DEFAULT (1) értékét kapná, ami a devizás beszerzést némán forintnak venné —
// ugyanaz a hiba, amit a `MGFBANKPLUG-MARGIN-B-T4M6` ír le. A már kitöltött értéket nem írjuk felül.
if (string.IsNullOrWhiteSpace(shippingDocument.CurrencyCode))
{
var documentPartner = await ctx.Partners.GetByIdAsync(shippingDocument.PartnerId);
var (currencyCode, fxRate) = await shippingCostService.ResolveDocumentCurrencyAsync(documentPartner);
shippingDocument.CurrencyCode = currencyCode;
shippingDocument.FxRate = fxRate;
}
await ctx.ShippingDocuments.InsertAsync(shippingDocument);
if(shippingDocument.ShippingItems != null)
@@ -0,0 +1,65 @@
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>
/// Költség-réteg fogyasztások (ADR 0002-R7Q2) — az árrés-modell egyetlen tárolt adata.
/// </summary>
/// <remarks>
/// Minden fogyasztó-szűrésnek a <c>ConsumerTypeId</c>-t IS tartalmaznia kell: a rendelés-tétel és a
/// leltár-tétel id-tartománya átfed, ezért a diszkriminátor nélküli szűrés csendben idegen sort ad.
/// Az itteni metódusok ezt betartják — kézi <c>Where</c> helyett ezeket használd.
/// </remarks>
public class CostConsumptionDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings)
: MgDbTableBase<CostConsumption>(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
{
public override IQueryable<CostConsumption> GetAll() => base.GetAll();
/// <summary>Relációkkal: a réteg tétele és annak dokumentuma — ennyi kell a dokumentum-szintű kimutatáshoz.</summary>
public IQueryable<CostConsumption> GetAll(bool loadRelations)
{
return loadRelations
? GetAll()
.LoadWith(c => c.ShippingItem).ThenLoad(si => si.ProductDto)
.LoadWith(c => c.ShippingItem).ThenLoad(si => si.ShippingDocument)
: GetAll();
}
/// <summary>Egy költség-réteg fogyasztásai — ebből jön a réteg egyenlege (beérkezett elfogyasztott).</summary>
public IQueryable<CostConsumption> GetAllByShippingItemId(int shippingItemId)
=> GetAll().Where(c => c.ShippingItemId == shippingItemId);
/// <summary>Több réteg fogyasztásai egy menetben — a FIFO-kiosztás így kerüli az N+1-et.</summary>
public IQueryable<CostConsumption> GetAllByShippingItemIds(IReadOnlyCollection<int> shippingItemIds)
=> GetAll().Where(c => c.ShippingItemId != null && shippingItemIds.Contains(c.ShippingItemId.Value));
/// <summary>Egy fogyasztó sorai — mi szolgálta ki, és miből.</summary>
public IQueryable<CostConsumption> GetAllByConsumer(CostConsumerType consumerType, int consumerId)
{
var typeId = (int)consumerType;
return GetAll().Where(c => c.ConsumerTypeId == typeId && c.ConsumerId == consumerId);
}
/// <summary>Több fogyasztó sorai egy menetben, egy fajtán belül.</summary>
public IQueryable<CostConsumption> GetAllByConsumers(CostConsumerType consumerType, IReadOnlyCollection<int> consumerIds)
{
var typeId = (int)consumerType;
return GetAll().Where(c => c.ConsumerTypeId == typeId && consumerIds.Contains(c.ConsumerId));
}
/// <summary>Egy időszak fogyasztásai — a havi bontás alapja; a dátum a rendelés lezárásának pillanata.</summary>
public IQueryable<CostConsumption> GetAllByPeriod(DateTime fromUtcInclusive, DateTime toUtcExclusive, bool loadRelations)
=> GetAll(loadRelations).Where(c => c.Created >= fromUtcInclusive && c.Created < toUtcExclusive);
/// <summary>Ismeretlen forrású fogyasztások — a riport külön során ezek jelennek meg.</summary>
public IQueryable<CostConsumption> GetAllWithoutLayer()
=> GetAll().Where(c => c.ShippingItemId == null);
public Task<CostConsumption> GetByIdAsync(int id) => GetAll().FirstOrDefaultAsync(c => c.Id == id);
}
@@ -44,9 +44,12 @@ public class FruitBankDbContext : MgDbContextBase,
IOrderItemPalletDbSet<OrderItemPalletDbTable>,
IShippingDocumentToFilesDbSet<ShippingDocumentToFilesDbTable>,
IFilesDbSet<FilesDbTable>,
ICustomerCreditDbSet<CustomerCreditDbTable>
ICustomerCreditDbSet<CustomerCreditDbTable>,
IShippingCostDbSet<ShippingCostDbTable>,
ICostConsumptionDbSet<CostConsumptionDbTable>
{
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly CostAttributionService _costAttributionService;
private readonly IStoreContext _storeContext;
private readonly IProductService _productService;
private readonly IStaticCacheManager _staticCacheManager;
@@ -72,6 +75,12 @@ public class FruitBankDbContext : MgDbContextBase,
/// <summary>Tétel↔kamion allocationök (ADR 0003) — a kamion rakományának igazságforrása.</summary>
public ShippingItemToShippingDbTable ShippingItemAllocations { get; set; }
/// <summary>Fuvardíj és egyéb szállítmány-költségek (ADR 0002-R7Q2).</summary>
public ShippingCostDbTable ShippingCosts { get; set; }
/// <summary>Költség-réteg fogyasztások (ADR 0002-R7Q2) — az árrés-modell egyetlen tárolt adata.</summary>
public CostConsumptionDbTable CostConsumptions { get; set; }
public OrderItemPalletDbTable OrderItemPallets { get; set; }
public FilesDbTable Files { get; set; }
@@ -101,6 +110,8 @@ public class FruitBankDbContext : MgDbContextBase,
StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, CustomerCreditDbTable customerCreditDbTable,
OrderDraftDbTable orderDraftDbTable, OrderDraftItemDbTable orderDraftItemDbTable,
ExternalUserPartnerMapDbTable externalUserPartnerMapDbTable, ProductTextMappingDbTable productTextMappingDbTable,
ShippingCostDbTable shippingCostDbTable, CostConsumptionDbTable costConsumptionDbTable,
CostAttributionService costAttributionService,
IProductService productService, IStaticCacheManager staticCacheManager,
IRepository<Order> orderRepository,
IRepository<OrderItem> orderItemRepository,
@@ -160,6 +171,10 @@ public class FruitBankDbContext : MgDbContextBase,
OrderDraftItems = orderDraftItemDbTable;
ExternalUserPartnerMaps = externalUserPartnerMapDbTable;
ProductTextMappings = productTextMappingDbTable;
ShippingCosts = shippingCostDbTable;
CostConsumptions = costConsumptionDbTable;
_costAttributionService = costAttributionService;
}
public IQueryable<Customer> GetCustomersBySystemRoleName(string systemRoleName)
@@ -563,6 +578,11 @@ public class FruitBankDbContext : MgDbContextBase,
await OrderItemPallets.UpdateAsync(orderItemPallet, false);
}
// A kiadott áru hozzárendelése a beszerzési rétegekhez (ADR 0002-R7Q2). Ugyanabban a tranzakcióban fut,
// mint a készlet-levonás — ugyanaz az esemény, nem külön mechanizmus. Nem mérendő tételre is kell,
// ezért az alábbi `continue` ELŐTT áll.
await _costAttributionService.AttributeOrderItemAsync(orderItemDto, revisorId);
if (!orderItemDto.IsMeasurable) continue;
var prevOrderItemNetWeightFromGa = orderItemDto.GenericAttributes.GetValueOrDefault<double>(nameof(IMeasuringNetWeight.NetWeight), 0);
@@ -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 ICostConsumptionDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<CostConsumption>
{
public TDbTable CostConsumptions { 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 IShippingCostDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<ShippingCost>
{
public TDbTable ShippingCosts { get; set; }
}
@@ -0,0 +1,37 @@
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>
/// Fuvardíj és egyéb szállítmány-költségek (ADR 0002-R7Q2). A sor tulajdonosa vagy a kamion, vagy a
/// szállítólevél — pontosan az egyik.
/// </summary>
public class ShippingCostDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings)
: MgDbTableBase<ShippingCost>(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
{
public override IQueryable<ShippingCost> GetAll() => base.GetAll();
/// <summary>Egy kamion költségei — a fuvardíj is ezek között van, <see cref="ShippingCostType.Freight"/> típussal.</summary>
public IQueryable<ShippingCost> GetAllByShippingId(int shippingId)
=> GetAll().Where(c => c.ShippingId == shippingId);
/// <summary>Egy szállítólevél saját költségei (vám, rakodás) — a kamion-szintűek NEM tartoznak ide.</summary>
public IQueryable<ShippingCost> GetAllByShippingDocumentId(int shippingDocumentId)
=> GetAll().Where(c => c.ShippingDocumentId == shippingDocumentId);
/// <summary>Több kamion költségei egy menetben — a riport kerüli vele az N+1-et.</summary>
public IQueryable<ShippingCost> GetAllByShippingIds(IReadOnlyCollection<int> shippingIds)
=> GetAll().Where(c => c.ShippingId != null && shippingIds.Contains(c.ShippingId.Value));
/// <summary>Több szállítólevél költségei egy menetben.</summary>
public IQueryable<ShippingCost> GetAllByShippingDocumentIds(IReadOnlyCollection<int> shippingDocumentIds)
=> GetAll().Where(c => c.ShippingDocumentId != null && shippingDocumentIds.Contains(c.ShippingDocumentId.Value));
public Task<ShippingCost> GetByIdAsync(int id) => GetAll().FirstOrDefaultAsync(c => c.Id == id);
}
@@ -0,0 +1,16 @@
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
/// <summary>Mi fogyasztotta el a költség-réteget.</summary>
/// <remarks>
/// Diszkriminátor a <see cref="CostConsumption.ConsumerId"/> mellé. A két id-tartomány ÁTFED, ezért a
/// diszkriminátor minden lekérdezésben kötelező — enélkül nem hiba keletkezik, hanem idegen sor.
/// Ugyanez a minta az <c>EkaerHistoryMapping.ForeignKey</c> + <c>IsOutgoing</c> páros.
/// </remarks>
public enum CostConsumerType
{
/// <summary>Eladás — a <c>ConsumerId</c> egy <c>OrderItem.Id</c>.</summary>
OrderItem = 10,
/// <summary>Leltári leírás — a <c>ConsumerId</c> egy <c>StockTakingItem.Id</c>.</summary>
StockTakingItem = 20
}
@@ -0,0 +1,80 @@
using AyCode.Core.Serializers.Toons;
using AyCode.Interfaces.TimeStampInfo;
using FruitBank.Common.Entities;
using LinqToDB.Mapping;
using Mango.Nop.Core.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
/// <summary>Egy beszerzésből (költség-rétegből) elfogyasztott mennyiség.</summary>
/// <remarks>
/// Az árrés-modell EGYETLEN tárolt adata (ADR 0002-R7Q2, 8. pont). Fajlagos önköltség, fuvar-hányad és árrés
/// mind lekérdezés — így egy később érkező helyesbített beszállítói vagy fuvarszámla visszamenőleg átüt a
/// riporton, újraszámoló futtatás nélkül.
///
/// A réteg a <see cref="ShippingItem"/> (egy szállítólevél-tétel), mert a kimutatás egysége a szállítólevél;
/// a kamion csak a fuvar-hányadon át jön be, az allocationökön keresztül. A sor ezért nem ismer kamiont — és
/// nem is függ a szállítmánytervező hátralévő fázisaitól.
///
/// Nincs <c>[AcBinarySerializable]</c>: ez a típus SOHA nem megy a wire-en — a költség- és árrés-adat admin-only.
/// </remarks>
[ToonDescription("Quantity consumed from one purchase (cost layer)",
Purpose = "The only stored fact of the margin model: how much of a given delivery-note line was consumed, " +
"and by what. Unit cost, freight share and margin are all derived by query, so a late price " +
"correction propagates backwards. A null ShippingItemId marks consumption with no known source.")]
[Table(Name = FruitBankPluginConst.CostConsumptionDbTableName)]
[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankPluginConst.CostConsumptionDbTableName)]
public sealed class CostConsumption : MgEntityBase, ITimeStampCreated, ITimeStampModified
{
/// <summary>A költség-réteg, amiből fogyott; null = ismeretlen forrású fogyasztás.</summary>
/// <remarks>
/// A null nem hibaállapot: induláskor a készleten álló áruhoz nincs réteg, és a szállítólevél-tétel törlése
/// is ide degradálja a sort (ON DELETE SET NULL) ahelyett, hogy az eladás-előzményt eldobná. A riportnak
/// KÜLÖN soron kell mutatnia — nulla önköltséggel beolvasztva hamis nyereséget adna.
/// </remarks>
[Column] public int? ShippingItemId { get; set; }
/// <summary>A fogyasztó fajtája (<see cref="CostConsumerType"/>), int-ként tárolva.</summary>
[Column] public int ConsumerTypeId { get; set; }
/// <summary>A fogyasztó fajtája — a <see cref="ConsumerTypeId"/> enum-burkolója.</summary>
[NotColumn]
public CostConsumerType ConsumerType
{
get => (CostConsumerType)ConsumerTypeId;
set => ConsumerTypeId = (int)value;
}
/// <summary>A fogyasztó sor azonosítója — a <see cref="ConsumerType"/> dönti el, melyik tábláé.</summary>
/// <remarks>
/// A két id-tartomány átfed, ezért a <see cref="ConsumerTypeId"/> szűrése minden lekérdezésben kötelező:
/// enélkül nem hiba keletkezik, hanem csendben idegen sor.
/// </remarks>
[Column] public int ConsumerId { get; set; }
/// <summary>Az elfogyasztott rekeszek száma — nem mérendő terméknél ez a fogyasztás mértéke.</summary>
[Column] public int Quantity { get; set; }
/// <summary>Az elfogyasztott nettó súly kg-ban — mérendő terméknél ez a fogyasztás mértéke.</summary>
[Column] public double NetWeight { get; set; }
/// <summary>Igaz, ha a hozzárendelést admin adta meg, nem a FIFO-szabály.</summary>
[Column] public bool IsManual { get; set; }
/// <summary>A költség-réteg, amiből fogyott.</summary>
[Association(ThisKey = nameof(ShippingItemId), OtherKey = nameof(ShippingItem.Id), CanBeNull = true)]
public ShippingItem ShippingItem { get; set; }
/// <summary>Létrehozás UTC időbélyege — egyben a havi bontás dátuma (a rendelés lezárásának pillanata).</summary>
/// <remarks>A CRUD-hook tölti, kézzel nem állítandó.</remarks>
public DateTime Created { get; set; }
/// <summary>A létrehozó felhasználó azonosítója.</summary>
public int? CreatorId { get; set; }
/// <summary>Módosítás UTC időbélyege — a CRUD-hook tölti, kézzel nem állítandó.</summary>
public DateTime Modified { get; set; }
/// <summary>A módosító felhasználó azonosítója.</summary>
public int? ModifierId { get; set; }
}
@@ -0,0 +1,81 @@
using AyCode.Core.Serializers.Toons;
using AyCode.Interfaces.TimeStampInfo;
using FruitBank.Common.Entities;
using LinqToDB.Mapping;
using Mango.Nop.Core.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
/// <summary>Fuvardíj vagy egyéb költség egy kamionon vagy egy szállítólevélen.</summary>
/// <remarks>
/// Egy tábla, két lehetséges tulajdonossal: a költség VAGY a kamionhoz (<see cref="ShippingId"/>), VAGY a
/// szállítólevélhez (<see cref="ShippingDocumentId"/>) tartozik — pontosan az egyikhez (DB-oldali CHECK).
/// A fuvardíj is csak egy sor itt, <see cref="ShippingCostType.Freight"/> típussal; a felület megjelenítheti
/// a kamionnál dedikált mezőként, a tárolás attól még egy mechanizmus. ADR 0002-R7Q2, 5. pont.
///
/// Nincs <c>[AcBinarySerializable]</c>: ez a típus SOHA nem megy a wire-en — a költség- és árrés-adat admin-only.
/// </remarks>
[ToonDescription("Freight or other cost booked on a truck or on a delivery note",
Purpose = "Cost pool of an inbound shipment. Owner is either the truck (ShippingId) or the delivery note " +
"(ShippingDocumentId) — exactly one. Amounts are stored NET, in the row's own currency, with the " +
"FX rate frozen at entry. Distributed onto the goods by pallet space.")]
[Table(Name = FruitBankPluginConst.ShippingCostDbTableName)]
[System.ComponentModel.DataAnnotations.Schema.Table(FruitBankPluginConst.ShippingCostDbTableName)]
public sealed class ShippingCost : MgEntityBase, ITimeStampCreated, ITimeStampModified
{
/// <summary>A kamion, amelyre a költség szól; null, ha a tulajdonos a szállítólevél.</summary>
[Column] public int? ShippingId { get; set; }
/// <summary>A szállítólevél, amelyre a költség szól; null, ha a tulajdonos a kamion.</summary>
[Column] public int? ShippingDocumentId { get; set; }
/// <summary>A költség fajtája (<see cref="ShippingCostType"/>), int-ként tárolva.</summary>
[Column] public int CostTypeId { get; set; }
/// <summary>A költség fajtája — a <see cref="CostTypeId"/> enum-burkolója.</summary>
[NotColumn]
public ShippingCostType CostType
{
get => (ShippingCostType)CostTypeId;
set => CostTypeId = (int)value;
}
/// <summary>Szabad szöveges megnevezés — az <see cref="ShippingCostType.Other"/> típusnál ez azonosítja a tételt.</summary>
[Column] public string Name { get; set; }
/// <summary>A költség NETTÓ összege a sor saját devizájában.</summary>
/// <remarks>
/// Mindig nettó, függetlenül attól, mi áll a papíron: a belföldi (áfás) számlát a felület számolja le a
/// <see cref="VatRate"/> alapján. Egyetlen jelentés, hogy ne lehessen félreolvasni (ADR 0002-R7Q2, 4. pont).
/// </remarks>
[Column] public decimal NetAmount { get; set; }
/// <summary>ISO 4217 pénznem-kód, amelyben a <see cref="NetAmount"/> áll.</summary>
[Column] public string CurrencyCode { get; set; }
/// <summary>A rögzítéskor befagyasztott árfolyam a <see cref="NetAmount"/> HUF-ra váltásához; HUF-nál 1.</summary>
[Column] public decimal FxRate { get; set; } = 1m;
/// <summary>A költség áfakulcsa százalékban (27.00 = 27%); az árrés nem használja, az áfa-analitika igen.</summary>
[Column] public decimal VatRate { get; set; }
/// <summary>A kamion, ha az a tulajdonos.</summary>
[Association(ThisKey = nameof(ShippingId), OtherKey = nameof(Shipping.Id), CanBeNull = true)]
public Shipping Shipping { get; set; }
/// <summary>A szállítólevél, ha az a tulajdonos.</summary>
[Association(ThisKey = nameof(ShippingDocumentId), OtherKey = nameof(ShippingDocument.Id), CanBeNull = true)]
public ShippingDocument ShippingDocument { get; set; }
/// <summary>Létrehozás UTC időbélyege — a CRUD-hook tölti, kézzel nem állítandó.</summary>
public DateTime Created { get; set; }
/// <summary>A létrehozó felhasználó azonosítója.</summary>
public int? CreatorId { get; set; }
/// <summary>Módosítás UTC időbélyege — a CRUD-hook tölti, kézzel nem állítandó.</summary>
public DateTime Modified { get; set; }
/// <summary>A módosító felhasználó azonosítója.</summary>
public int? ModifierId { get; set; }
}
@@ -0,0 +1,17 @@
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
/// <summary>Egy szállítmány-költség fajtája.</summary>
public enum ShippingCostType
{
/// <summary>Fuvardíj — a kamionra szól, raklap-arányosan oszlik a rakományra.</summary>
Freight = 10,
/// <summary>Vám.</summary>
Customs = 20,
/// <summary>Rakodás.</summary>
Handling = 30,
/// <summary>Minden más — a megnevezést a <c>Name</c> hordozza.</summary>
Other = 99
}
@@ -43,5 +43,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
/// <summary>License plate (rendszám) of the vehicle picking up the order. Raw; the NAV mapper normalizes.</summary>
public const string OrderLicensePlateAttribute = "LicensePlate";
// ── Plugin-owned table names (NOT shared with the Hybrid client) ─────────
// The shared entities' table names live in FruitBankConstClient (FruitBank.Common), because those
// entities go over SignalR. Cost and margin data is admin-only (ADR 0002-R7Q2), so both the entity
// and its table name stay on the plugin side.
/// <summary>Freight and other shipment costs.</summary>
public const string ShippingCostDbTableName = "fbShippingCost";
/// <summary>Quantity consumed from one purchase (cost layer).</summary>
public const string CostConsumptionDbTableName = "fbCostConsumption";
}
}
@@ -113,6 +113,11 @@ public class PluginNopStartup : INopStartup
services.AddScoped<ShippingItemDbTable>();
services.AddScoped<ShippingItemPalletDbTable>();
services.AddScoped<ShippingItemToShippingDbTable>();
services.AddScoped<ShippingCostDbTable>();
services.AddScoped<CostConsumptionDbTable>();
services.AddScoped<CostAttributionService>();
services.AddScoped<ShippingCostService>();
services.AddScoped<MarginCalculationService>();
services.AddScoped<FilesDbTable>();
services.AddScoped<ShippingDocumentToFilesDbTable>();
@@ -8,6 +8,7 @@ using Nop.Core.Domain.Common;
using Nop.Core.Domain.Orders;
using Nop.Data.Mapping;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Mapping;
@@ -54,6 +55,10 @@ public partial class NameCompatibility : INameCompatibility
{ typeof(PartnerDepot), FruitBankConstClient.PartnerDepotDbTableName},
{ typeof(EkaerHistory), FruitBankConstClient.EkaerHistoryDbTableName},
{ typeof(EkaerHistoryMapping), FruitBankConstClient.EkaerHistoryMappingDbTableName},
// Plugin-tulajdonú, a klienssel NEM megosztott táblák (ADR 0002-R7Q2) — a nevük a FruitBankPluginConst-ban.
{ typeof(ShippingCost), FruitBankPluginConst.ShippingCostDbTableName},
{ typeof(CostConsumption), FruitBankPluginConst.CostConsumptionDbTableName},
};
@@ -202,6 +202,9 @@
<None Update="Areas\Admin\Views\InnVoiceOrderSync\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\MarginReport\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Order\FileUploadGridComponent.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@@ -250,6 +253,9 @@
<None Update="Areas\Admin\Views\Product\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\ShippingCost\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Shipping\Edit.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,155 @@
using AyCode.Core.Loggers;
using FruitBank.Common.Dtos;
using LinqToDB;
using Mango.Nop.Core.Loggers;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
/// <summary>Hozzárendeli a kiadott árut a beszerzésekhez (költség-rétegekhez) FIFO szerint.</summary>
/// <remarks>
/// ADR 0002-R7Q2. A raktáros nem jelöli, melyik raklapról szedte le az árut, tehát a „melyik szállítmányból
/// fogyott" kérdésre szabály felel, nem mérés: a legrégebbi, még el nem fogyott beszerzés fogy először.
/// Romlandó árunál ez egyszerre a fizikai valóság és a számviteli konvenció.
///
/// A fogyasztás VEZÉRLŐ mértékegysége terméktípus-függő, és követi az árazást: mérendő terméknél **kg**, nem
/// mérendőnél **rekesz** — ebben a dimenzióban fogy a réteg, és ez az egzakt érték. A sor a másik dimenziót is
/// kitölti, a tétel összesenjének arányában; egyetlen rétegnél (a tipikus eset) az is pontos.
///
/// Ha a rétegek elfogynak (indulás előtti készlet, negatív készlet), a maradék **ismeretlen forrású** sorként
/// keletkezik (<c>ShippingItemId = null</c>). Nulla önköltséggel beolvasztani tilos: az hamis nyereséget mutatna.
/// </remarks>
public class CostAttributionService(
CostConsumptionDbTable costConsumptions,
ShippingItemDbTable shippingItems,
IEnumerable<IAcLogWriterBase> logWriters)
{
private readonly Logger<CostAttributionService> _logger = new(logWriters.ToArray());
/// <summary>A kg-ban mért maradék, ami alatt a kiosztást befejezettnek tekintjük (lebegőpontos maradék).</summary>
private const double WeightEpsilon = 0.05d;
/// <summary>Egy rendelés-tétel áruját szétosztja a beszerzési rétegek között; a hívó tranzakciójában fut.</summary>
/// <returns>A létrehozott fogyasztás-sorok száma; 0, ha nem volt mit kiosztani.</returns>
public async Task<int> AttributeOrderItemAsync(OrderItemDto orderItemDto, int? creatorId = null)
{
ArgumentNullException.ThrowIfNull(orderItemDto);
// Idempotencia: a lezárás nem futhat kétszer ugyanarra a rendelésre, de ha mégis, ne duplázzunk.
// A hívás minősítve: az `AnyAsync()` nevet a LinqToDB és a System.Linq aszinkron kiterjesztése is viszi.
var alreadyDone = await LinqToDB.AsyncExtensions.AnyAsync(
costConsumptions.GetAllByConsumer(CostConsumerType.OrderItem, orderItemDto.Id));
if (alreadyDone) return 0;
var isMeasurable = orderItemDto.IsMeasurable;
// A vezérlő dimenzió: mérendőnél a mért nettó súly, nem mérendőnél a rekeszszám.
var needed = isMeasurable ? double.Round(orderItemDto.NetWeight, 1) : orderItemDto.Quantity;
if (needed <= 0) return 0;
var layers = await LoadLayersAsync(orderItemDto.ProductId, isMeasurable);
// A tétel összesenjei a MÁSIK dimenzió arányosításához; a vezérlő dimenzió mindig egzakt.
var totals = new ItemTotals(needed, orderItemDto.Quantity, double.Round(orderItemDto.NetWeight, 1));
var created = 0;
foreach (var layer in layers)
{
if (needed <= (isMeasurable ? WeightEpsilon : 0)) break;
if (layer.Remaining <= 0) continue;
var take = Math.Min(layer.Remaining, needed);
await InsertConsumptionAsync(layer.ShippingItemId, orderItemDto.Id, isMeasurable, take, totals, false, creatorId);
needed -= take;
created++;
}
// Ami nem talált réteget: ismeretlen forrású. A riport külön során jelenik meg.
if (needed > (isMeasurable ? WeightEpsilon : 0))
{
await InsertConsumptionAsync(null, orderItemDto.Id, isMeasurable, needed, totals, false, creatorId);
created++;
_logger.Warning($"CostAttributionService: ismeretlen forrású fogyasztás. " +
$"OrderItem.Id: {orderItemDto.Id}, Product.Id: {orderItemDto.ProductId}, " +
$"maradék: {needed:0.###} {(isMeasurable ? "kg" : "rekesz")}");
}
return created;
}
/// <summary>A termék beszerzési rétegei, a legrégebbi elöl, a még ki nem adott mennyiségükkel.</summary>
private async Task<List<LayerBalance>> LoadLayersAsync(int productId, bool isMeasurable)
{
// Csak a lemért (bevételezett) tétel lehet réteg: a fajlagos önköltséghez a MÉRT mennyiség az osztó.
var candidates = await shippingItems.GetAll()
.Where(si => si.ProductId == productId && si.IsMeasured)
.OrderBy(si => si.ShippingDocument.ShippingDate)
.ThenBy(si => si.Id)
.Select(si => new { si.Id, si.MeasuredQuantity, si.MeasuredNetWeight })
.ToListAsync();
if (candidates.Count == 0) return [];
var layerIds = candidates.Select(c => c.Id).ToList();
// Az összegzés SZÁNDÉKOSAN memóriában fut: a LinqToDB a `GroupBy` + kétszeres `Sum` alakot nem fordította
// le ezen az entitáson (`Expression '_.NetWeight' is not a Field`). Egy termék rétegeinek fogyasztás-sorai
// kevesen vannak, tehát az áthozott sorok száma kicsi — a szerver-oldali csoportosítás itt nem éri meg a
// törékenységet.
var consumptionRows = await costConsumptions
.GetAllByShippingItemIds(layerIds)
.Select(c => new { c.ShippingItemId, c.Quantity, c.NetWeight })
.ToListAsync();
var consumedByLayer = consumptionRows
.Where(r => r.ShippingItemId.HasValue)
.GroupBy(r => r.ShippingItemId!.Value)
.ToDictionary(g => g.Key, g => isMeasurable ? g.Sum(x => x.NetWeight) : g.Sum(x => x.Quantity));
return candidates
.Select(c =>
{
var capacity = isMeasurable ? c.MeasuredNetWeight : c.MeasuredQuantity;
var consumed = consumedByLayer.GetValueOrDefault(c.Id, 0d);
return new LayerBalance(c.Id, capacity - consumed);
})
.ToList();
}
/// <summary>Mindkét dimenziót kitölti: a vezérlőt egzaktul, a másikat a tétel arányában.</summary>
/// <remarks>
/// A másik dimenzió nem kitalált szám: nem mérendő terméknél a súly az <c>AverageWeight × rekesz</c> képletből
/// egzaktul adódik (FBANKAPP ADR 0001), mérendőnél a rekeszszám a tétel saját összesenjéből. Egyetlen rétegnél
/// — a tipikus eset — az arány 1, tehát mindkét érték pontos; bontásnál a vezérlő dimenzió marad a hiteles.
/// </remarks>
private async Task InsertConsumptionAsync(int? shippingItemId, int orderItemId, bool isMeasurable, double amount, ItemTotals totals, bool isManual, int? creatorId)
{
var ratio = totals.Driving > 0 ? amount / totals.Driving : 0d;
var consumption = new CostConsumption
{
ShippingItemId = shippingItemId,
ConsumerType = CostConsumerType.OrderItem,
ConsumerId = orderItemId,
Quantity = isMeasurable ? (int)Math.Round(totals.Quantity * ratio) : (int)Math.Round(amount),
NetWeight = isMeasurable ? double.Round(amount, 1) : double.Round(totals.NetWeight * ratio, 1),
IsManual = isManual,
CreatorId = creatorId
};
await costConsumptions.InsertAsync(consumption);
}
/// <summary>Egy réteg és a még ki nem adott mennyisége a vezérlő dimenzióban.</summary>
private sealed record LayerBalance(int ShippingItemId, double Remaining);
/// <summary>A rendelés-tétel összesenjei: a vezérlő dimenzió és mindkét mértékegység teljes értéke.</summary>
private sealed record ItemTotals(double Driving, int Quantity, double NetWeight);
}
@@ -293,6 +293,28 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
shippingConfigurationItem.ChildNodes.Insert(5, productMasterDataMenuItem);
var shippingCostMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "FruitBank",
Title = "Szállítmány-költségek",
IconClass = "fas fa-coins",
Url = _adminMenu.GetMenuItemUrl("ShippingCost", "Index")
};
shippingConfigurationItem.ChildNodes.Insert(6, shippingCostMenuItem);
var marginReportMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "FruitBank",
Title = "Árrés-kimutatás",
IconClass = "fas fa-chart-line",
Url = _adminMenu.GetMenuItemUrl("MarginReport", "Index")
};
shippingConfigurationItem.ChildNodes.Insert(7, marginReportMenuItem);
var preordersRootMenuItem = new AdminMenuItem
{
Visible = true,
@@ -0,0 +1,274 @@
using LinqToDB;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
/// <summary>Árrés-számítás: eladott áru bevétele mínusz önköltsége, fuvar- és extra-hányada.</summary>
/// <remarks>
/// ADR 0002-R7Q2. Minden szám LEKÉRDEZÉSBŐL áll elő, semmi nincs tárolva — így egy később érkező helyesbített
/// beszállítói vagy fuvarszámla visszamenőleg átüt a riporton, újraszámoló futtatás nélkül.
///
/// Az elszámolás egysége a szállítólevél-tétel (<c>ShippingItem</c>); kamionra és hónapra ebből összegzünk.
/// Minden érték NETTÓ és HUF: a beszerzés a dokumentum befagyasztott árfolyamával, az eladás eleve forintban.
/// </remarks>
public class MarginCalculationService(
CostConsumptionDbTable costConsumptions,
ShippingItemDbTable shippingItems,
ShippingCostDbTable shippingCosts,
FruitBankDbContext dbContext)
{
/// <summary>Egy időszak árrés-sorai, a fogyasztás (= a rendelés lezárása) dátuma szerint szűrve.</summary>
/// <remarks>Az időszak az ELADÁS hónapja, nem a beérkezésé — az árbevétel akkor realizálódik.</remarks>
public async Task<List<MarginRow>> CalculateForPeriodAsync(DateTime fromUtcInclusive, DateTime toUtcExclusive)
{
var consumptions = await costConsumptions
.GetAllByPeriod(fromUtcInclusive, toUtcExclusive, false)
.Where(c => c.ConsumerTypeId == (int)CostConsumerType.OrderItem)
.Select(c => new { c.ShippingItemId, c.ConsumerId, c.Quantity, c.NetWeight, c.Created })
.ToListAsync();
if (consumptions.Count == 0) return [];
// ── Eladási oldal: a rendelés-tételek nettó egységára ────────────────────
var orderItemIds = consumptions.Select(c => c.ConsumerId).Distinct().ToList();
var salePrices = (await dbContext.OrderItemDtos.GetAll()
.Where(oi => orderItemIds.Contains(oi.Id))
.Select(oi => new { oi.Id, oi.UnitPriceExclTax })
.ToListAsync())
.ToDictionary(x => x.Id, x => x.UnitPriceExclTax);
// ── Rétegek: a beszerzési oldal ─────────────────────────────────────────
var layerIds = consumptions.Where(c => c.ShippingItemId.HasValue)
.Select(c => c.ShippingItemId!.Value).Distinct().ToList();
var layers = await shippingItems.GetAll()
.Where(si => layerIds.Contains(si.Id))
.Select(si => new
{
si.Id,
si.ShippingDocumentId,
si.ProductId,
si.Name,
si.IsMeasurable,
si.MeasuredQuantity,
si.MeasuredNetWeight,
si.Pallets,
si.NetTotalOnDocument,
documentFxRate = si.ShippingDocument.FxRate,
documentCurrency = si.ShippingDocument.CurrencyCode,
documentNumber = si.ShippingDocument.DocumentIdNumber,
documentDate = si.ShippingDocument.ShippingDate
})
.ToListAsync();
var (freightByItem, truckSharesByItem) = await CalculateFreightAndTruckSharesAsync(layerIds);
var extraByItem = await CalculateDocumentExtraShareByItemAsync(layers.Select(l => l.ShippingDocumentId).Distinct().ToList());
// ── Soronkénti összeállítás ─────────────────────────────────────────────
var rows = new List<MarginRow>();
// A csoportosítás kulcsa a (réteg, HÓNAP) pár: enélkül a havi bontás nem állna elő, mert a fogyasztás
// dátuma nem jutna el a sorig. A dokumentum- és kamion-bontás ettől függetlenül összegez a hónapokon át.
foreach (var group in consumptions.GroupBy(c => new { c.ShippingItemId, Period = c.Created.Year * 100 + c.Created.Month }))
{
var layer = group.Key.ShippingItemId.HasValue
? layers.FirstOrDefault(l => l.Id == group.Key.ShippingItemId.Value)
: null;
var isMeasurable = layer?.IsMeasurable ?? false;
var sold = group.Sum(c => isMeasurable ? c.NetWeight : c.Quantity);
var revenue = group.Sum(c => (decimal)(isMeasurable ? c.NetWeight : c.Quantity)
* salePrices.GetValueOrDefault(c.ConsumerId, 0m));
// Ismeretlen forrású fogyasztás: van bevétel, de nincs mihez kötni az önköltséget.
if (layer == null)
{
rows.Add(new MarginRow(null, null, null, "Ismeretlen forrású", false,
0d, sold, 0d, 0m, 0m, 0m, revenue, revenue, true, [], group.Key.Period, null, null, null, 1m));
continue;
}
var received = isMeasurable ? layer.MeasuredNetWeight : layer.MeasuredQuantity;
// A fajlagos önköltség a MÉRT mennyiségre vetül: a papír és a valóság közti hiány így már benne van.
var costHuf = layer.NetTotalOnDocument * layer.documentFxRate;
var unitCost = received > 0 ? costHuf / (decimal)received : 0m;
var goodsCost = unitCost * (decimal)sold;
// A fuvar és az extra a TELJES tételre szól — az eladott hányadra arányosítjuk.
var soldRatio = received > 0 ? (decimal)(sold / received) : 0m;
var freightShare = freightByItem.GetValueOrDefault(layer.Id, 0m) * soldRatio;
var extraShare = extraByItem.GetValueOrDefault(layer.Id, 0m) * soldRatio;
rows.Add(new MarginRow(
layer.Id, layer.ShippingDocumentId, layer.ProductId, layer.Name, isMeasurable,
received, sold, received - sold,
goodsCost, freightShare, extraShare,
revenue, revenue - goodsCost - freightShare - extraShare, false,
truckSharesByItem.GetValueOrDefault(layer.Id, []), group.Key.Period,
layer.documentNumber, layer.documentDate, layer.documentCurrency, layer.documentFxRate));
}
return rows;
}
/// <summary>A kamion-szintű költségek tételre eső része, raklap-arányosan.</summary>
/// <remarks>
/// A fuvarozó HELYET ad el, ezért a kulcs a raklap. Az osztó a kamionon TÉNYLEGESEN ELFOGLALT raklapok
/// összege, nem a kapacitás: egy félig pakolt kamion drágábbá teszi az árut, ami gazdaságilag helyes.
/// Ugyanez a képlet érvényes a gyűjtőjáratra is, ahol a díj eleve csak a mi részünkre szól — nincs külön ág.
/// </remarks>
private async Task<(Dictionary<int, decimal> FreightByItem, Dictionary<int, List<TruckShare>> TruckSharesByItem)>
CalculateFreightAndTruckSharesAsync(List<int> shippingItemIds)
{
var allocations = await dbContext.ShippingItemAllocations.GetAll()
.Where(a => shippingItemIds.Contains(a.ShippingItemId))
.Select(a => new { a.Id, a.ShippingItemId, a.ShippingId, a.Pallets })
.ToListAsync();
if (allocations.Count == 0) return ([], []);
// A tétel megoszlása a kamionok között — ebből tud a riport kamion-bontást adni akkor is, ha egy
// szállítólevél-tétel áruja több kamionra bomlott. Egyetlen allocationnél az arány 1.
var truckSharesByItem = allocations
.GroupBy(a => a.ShippingItemId)
.ToDictionary(
g => g.Key,
g =>
{
var itemPallets = g.Sum(a => a.Pallets);
return g.Select(a => new TruckShare(
a.ShippingId,
itemPallets > 0 ? a.Pallets / itemPallets : 1d / g.Count()))
.ToList();
});
var truckIds = allocations.Select(a => a.ShippingId).Distinct().ToList();
// A kamion TELJES foglaltsága — az összes rajta lévő allocation, nem csak a most vizsgált tételeké.
var truckTotals = (await dbContext.ShippingItemAllocations.GetAll()
.Where(a => truckIds.Contains(a.ShippingId))
.Select(a => new { a.ShippingId, a.Pallets })
.ToListAsync())
.GroupBy(a => a.ShippingId)
.ToDictionary(g => g.Key, g => g.Sum(x => x.Pallets));
var truckCosts = (await shippingCosts.GetAllByShippingIds(truckIds)
.Select(c => new { c.ShippingId, c.NetAmount, c.FxRate })
.ToListAsync())
.GroupBy(c => c.ShippingId!.Value)
.ToDictionary(g => g.Key, g => g.Sum(x => x.NetAmount * x.FxRate));
var result = new Dictionary<int, decimal>();
foreach (var allocation in allocations)
{
var truckCost = truckCosts.GetValueOrDefault(allocation.ShippingId, 0m);
if (truckCost == 0m) continue;
var truckPallets = truckTotals.GetValueOrDefault(allocation.ShippingId, 0d);
if (truckPallets <= 0) continue;
var share = truckCost * (decimal)(allocation.Pallets / truckPallets);
result[allocation.ShippingItemId] = result.GetValueOrDefault(allocation.ShippingItemId, 0m) + share;
}
return (result, truckSharesByItem);
}
/// <summary>A szállítólevél-szintű költségek tételre eső része, ugyanazzal a raklap-kulccsal.</summary>
private async Task<Dictionary<int, decimal>> CalculateDocumentExtraShareByItemAsync(List<int> documentIds)
{
var documentCosts = (await shippingCosts.GetAllByShippingDocumentIds(documentIds)
.Select(c => new { c.ShippingDocumentId, c.NetAmount, c.FxRate })
.ToListAsync())
.GroupBy(c => c.ShippingDocumentId!.Value)
.ToDictionary(g => g.Key, g => g.Sum(x => x.NetAmount * x.FxRate));
if (documentCosts.Count == 0) return [];
// Csak azoknak a dokumentumoknak a tételeit kell betölteni, ahol egyáltalán van költség.
var relevantDocumentIds = documentCosts.Keys.ToList();
var itemsOfDocuments = await shippingItems.GetAll()
.Where(si => relevantDocumentIds.Contains(si.ShippingDocumentId))
.Select(si => new { si.Id, si.ShippingDocumentId, si.Pallets })
.ToListAsync();
var result = new Dictionary<int, decimal>();
foreach (var documentGroup in itemsOfDocuments.GroupBy(si => si.ShippingDocumentId))
{
var totalPallets = documentGroup.Sum(si => si.Pallets);
if (totalPallets <= 0) continue;
var cost = documentCosts.GetValueOrDefault(documentGroup.Key, 0m);
if (cost == 0m) continue;
foreach (var item in documentGroup)
result[item.Id] = cost * (decimal)(item.Pallets / totalPallets);
}
return result;
}
}
/// <summary>Egy szállítólevél-tétel árrése az adott időszakban eladott mennyiségre.</summary>
/// <param name="ShippingItemId">A költség-réteg; null az ismeretlen forrású sornál.</param>
/// <param name="Received">A bevételezett mennyiség a vezérlő dimenzióban (kg vagy rekesz).</param>
/// <param name="Sold">Az időszakban eladott mennyiség ugyanabban a dimenzióban.</param>
/// <param name="Remaining">A rétegben maradt mennyiség — készleten vagy leírva.</param>
public sealed record MarginRow(
int? ShippingItemId,
int? ShippingDocumentId,
int? ProductId,
string ProductName,
bool IsMeasurable,
double Received,
double Sold,
double Remaining,
decimal GoodsCostHuf,
decimal FreightShareHuf,
decimal ExtraShareHuf,
decimal RevenueHuf,
decimal MarginHuf,
bool IsUnknownSource,
IReadOnlyList<TruckShare> TruckShares,
int Period,
string DocumentNumber,
DateTime? DocumentDate,
string DocumentCurrency,
decimal DocumentFxRate)
{
/// <summary>A dokumentum árfolyama nem megbízható — az önköltség forintosítása hibás lehet.</summary>
/// <remarks>
/// Két eset: (a) a dokumentumon nincs pénznem (a feature előtti sorok), (b) a pénznem NEM forint, az árfolyam
/// viszont 1 — ilyenkor az eurót forintnak vettük. Az oszlop <c>DEFAULT (1)</c> értéke miatt ez a régi
/// dokumentumoknál rendszerszerű, és a hiba iránya mindig ugyanaz: az önköltség töredékének látszik.
/// </remarks>
public bool HasUntrustworthyFxRate
=> !IsUnknownSource && GoodsCostHuf > 0m
&& (string.IsNullOrWhiteSpace(DocumentCurrency)
|| (!DocumentCurrency.Equals("HUF", StringComparison.OrdinalIgnoreCase) && DocumentFxRate <= 1m));
/// <summary>Nincs a tétel egyetlen kamionhoz sem rendelve — ezért a fuvar-hányada szükségképpen 0.</summary>
/// <remarks>
/// Meg kell különböztetni a „nincs fuvarköltség" esettől: az egyik adathiány, a másik valós nulla. A régi,
/// a szállítmánytervező előtti szállítmányoknál gyakori, hogy nincs allocation.
/// </remarks>
public bool HasNoTruckAllocation => !IsUnknownSource && TruckShares.Count == 0;
/// <summary>Árrés-százalék az árbevételre vetítve; 0 bevételnél 0.</summary>
public decimal MarginPercent => RevenueHuf > 0 ? Math.Round(MarginHuf / RevenueHuf * 100m, 2) : 0m;
/// <summary>Az eladás hónapja <c>yyyy-MM</c> alakban — a <see cref="Period"/> olvasható párja.</summary>
public string PeriodLabel => $"{Period / 100:0000}-{Period % 100:00}";
}
/// <summary>Egy tétel árujának hányada egy adott kamionon — a kamion-bontás súlya.</summary>
/// <param name="Ratio">0..1; egyetlen allocationnél 1.</param>
public sealed record TruckShare(int ShippingId, double Ratio);
@@ -824,7 +824,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
"'quantity' (int - the number of cartons, boxes or packages), " +
"'netWeight' (double - the net kilograms in European format, example: 1.372 kgs should be 1372,00 kgs), " +
"'grossWeight' (double - the gross kilogramsin European format, example: 1.372 kgs should be 1372,00 kgs)," +
"'unitCost (double - the unit price of the product on the document)'.\r \n \n" +
"'unitCost (double - the unit price of the product on the document)', " +
"'netTotal' (double - the NET line total of the product on the document, i.e. the row's own " +
"amount before VAT)'.\r \n \n" +
"";
string systemPrompt = "You are an AI assistant of FRUITBANK that extracts text and structured data from images. " +
@@ -0,0 +1,164 @@
using AyCode.Core.Loggers;
using LinqToDB;
using Mango.Nop.Core.Loggers;
using FruitBank.Common.Entities;
using Nop.Core.Domain.Directory;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.Entities;
using Nop.Services.Directory;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
/// <summary>Szállítmány-költségek (fuvardíj, vám, rakodás, egyéb) kezelése és a devizájuk forintosítása.</summary>
/// <remarks>
/// ADR 0002-R7Q2. A költség tulajdonosa VAGY a kamion, VAGY a szállítólevél — pontosan az egyik (DB-oldali CHECK).
/// Az összeg mindig NETTÓ, a sor saját devizájában, a rögzítéskor befagyasztott árfolyammal.
/// </remarks>
public class ShippingCostService(
ShippingCostDbTable shippingCosts,
ICurrencyService currencyService,
CurrencySettings currencySettings,
IEnumerable<IAcLogWriterBase> logWriters)
{
private readonly Logger<ShippingCostService> _logger = new(logWriters.ToArray());
/// <summary>Egy deviza HUF-árfolyama a rögzítés pillanatában; az elsődleges pénznemnél 1, ismeretlennél 0.</summary>
/// <remarks>
/// Forrás a nopCommerce **saját** pénznem-nyilvántartása (`Currency.Rate`, az EKB-provider és az ütemezett
/// `UpdateExchangeRateTask` tartja karban), a konverziót a `ICurrencyService` végzi — így a pivot-pénznem
/// kezelése sem itt van kitalálva.
///
/// ⚠️ **Ez NEM az EKÁER árfolyama, és nem is szabad összevonni vele.** A NAV a bejelentéshez az általa
/// megadott, FIX ÉVES árfolyamot követeli (`Ekaer:ExchangeRate:EurHuf`) — az jogi artefaktum. Az önköltség
/// ezzel szemben számviteli szám, aminek a tényleges átváltás felel meg. Ugyanahhoz a dokumentumhoz ezért
/// jogosan tartozhat két különböző forintérték; a kettő egyesítése hibás lenne.
///
/// Ismeretlen pénznemnél 0-t adunk vissza, nem 1-et: az 1-es szorzó forintnak venné a devizát, és az így
/// keletkező hiba mindig ugyanabba az irányba visz — az önköltség töredékére, az árrés felfelé
/// (`MGFBANKPLUG-MARGIN-B-T4M6`).
/// </remarks>
public async Task<decimal> ResolveFxRateAsync(string currencyCode)
{
var primaryCurrency = await currencyService.GetCurrencyByIdAsync(currencySettings.PrimaryStoreCurrencyId);
if (primaryCurrency == null)
{
_logger.Warning("ShippingCostService: nincs elsődleges bolti pénznem, az árfolyam nem oldható fel.");
return 0m;
}
if (string.IsNullOrWhiteSpace(currencyCode))
{
_logger.Warning("ShippingCostService: a rekordon nincs pénznem, ezért árfolyam sem rendelhető hozzá.");
return 0m;
}
if (currencyCode.Trim().Equals(primaryCurrency.CurrencyCode, StringComparison.OrdinalIgnoreCase))
return 1m;
var sourceCurrency = await currencyService.GetCurrencyByCodeAsync(currencyCode.Trim());
if (sourceCurrency == null || sourceCurrency.Rate <= 0)
{
_logger.Warning($"ShippingCostService: a(z) {currencyCode} pénznem nincs felvéve a nopCommerce pénznemei közé, vagy nincs árfolyama.");
return 0m;
}
try
{
// Egy egység átváltása adja meg magát az árfolyamot — a szorzást a hívó végzi az összegen.
// A nop a pivot-pénznemmel OSZT (`ConvertToPrimaryExchangeRateCurrencyAsync`), tehát EUR `Rate`=0,0028
// mellett ez 1/0,0028 ≈ 357 Ft-ot ad, nem 0,0028-at.
var rate = await currencyService.ConvertToPrimaryStoreCurrencyAsync(1m, sourceCurrency);
if (rate > 0) return rate;
_logger.Warning($"ShippingCostService: a(z) {currencyCode} átváltása 0-t adott, az árfolyam nem használható.");
return 0m;
}
catch (Exception ex)
{
// A nop dob, ha a pivot-pénznem nem tölthető be vagy nincs árfolyama. A bevételezés és a
// költség-rögzítés ettől nem hasalhat el: 0-t tárolunk, ami a riportban látható hiány.
_logger.Warning($"ShippingCostService: a(z) {currencyCode} árfolyama nem oldható fel ({ex.Message}); a rekord árfolyam nélkül mentődik.");
return 0m;
}
}
/// <summary>Egy szállítólevél pénzneme és a bevételezéskor befagyasztott árfolyama, a beszállítóból levezetve.</summary>
/// <remarks>
/// Belföldi (vagy országkód nélküli) partnernél a hiányzó pénznem forintra egészül ki — ott az 1-es árfolyam
/// helyes. Külföldinél NEM pótolunk: ismeretlen pénznemhez nem szabad árfolyamot rendelni, mert az 1-es
/// szorzó némán forintnak venné a devizát (`MGFBANKPLUG-MARGIN-B-T4M6`).
///
/// Minden dokumentum-létrehozó útnak ezt kell hívnia — az AI-s feltöltésnek és a SignalR-en érkezőnek is.
/// </remarks>
public async Task<(string CurrencyCode, decimal FxRate)> ResolveDocumentCurrencyAsync(Partner partner)
{
var isDomestic = string.IsNullOrWhiteSpace(partner?.CountryCode)
|| partner.CountryCode.Trim().Equals("HU", StringComparison.OrdinalIgnoreCase);
var currencyCode = !string.IsNullOrWhiteSpace(partner?.Currency)
? partner.Currency.Trim()
: (isDomestic ? "HUF" : null);
return (currencyCode, await ResolveFxRateAsync(currencyCode));
}
/// <summary>Egy kamion költségei.</summary>
public Task<List<ShippingCost>> GetByShippingIdAsync(int shippingId)
=> shippingCosts.GetAllByShippingId(shippingId).OrderBy(c => c.Id).ToListAsync();
/// <summary>Egy szállítólevél saját költségei — a kamion-szintűek NEM tartoznak ide.</summary>
public Task<List<ShippingCost>> GetByShippingDocumentIdAsync(int shippingDocumentId)
=> shippingCosts.GetAllByShippingDocumentId(shippingDocumentId).OrderBy(c => c.Id).ToListAsync();
/// <summary>Költség létrehozása vagy módosítása; az árfolyam a mentés pillanatában rögzül.</summary>
/// <returns>A mentett sor, vagy null, ha a tulajdonos nem pontosan egy.</returns>
public async Task<ShippingCost> SaveAsync(int id, int? shippingId, int? shippingDocumentId, ShippingCostType costType,
string name, decimal netAmount, string currencyCode, decimal vatRate, int? userId)
{
// A DB-ben is CHECK őrzi, de itt értelmes hibát tudunk adni a felületnek.
var ownerCount = (shippingId.HasValue ? 1 : 0) + (shippingDocumentId.HasValue ? 1 : 0);
if (ownerCount != 1) return null;
if (netAmount < 0) return null;
var cost = id > 0 ? await shippingCosts.GetByIdAsync(id) : null;
var isNew = cost == null;
cost ??= new ShippingCost();
cost.ShippingId = shippingId;
cost.ShippingDocumentId = shippingDocumentId;
cost.CostType = costType;
cost.Name = name;
cost.NetAmount = netAmount;
cost.CurrencyCode = currencyCode;
cost.VatRate = vatRate;
cost.FxRate = await ResolveFxRateAsync(currencyCode);
if (isNew)
{
cost.CreatorId = userId;
await shippingCosts.InsertAsync(cost);
}
else
{
cost.ModifierId = userId;
await shippingCosts.UpdateAsync(cost);
}
return cost;
}
/// <summary>Költség törlése.</summary>
public async Task<bool> DeleteAsync(int id)
{
var cost = await shippingCosts.GetByIdAsync(id);
if (cost == null) return false;
await shippingCosts.DeleteAsync(cost);
return true;
}
}
@@ -103,6 +103,20 @@ StockTaking (fbStockTaking) ← inventory session
└─ ProductDto (Product) [N:1]
```
### Beszerzési önköltség és árrés
```
ShippingItem (fbShippingItem) ← a költség-réteg: egy szállítólevél-tétel
└─ CostConsumption (fbCostConsumption) ← mennyi fogyott belőle, és mi fogyasztotta [1:N]
Shipping / ShippingDocument
└─ ShippingCost (fbShippingCost) ← fuvardíj / vám / rakodás / egyéb [1:N]
```
A `CostConsumption` fogyasztója polimorf (`ConsumerTypeId` + `ConsumerId`): rendelés-tétel vagy leltár-tétel.
A `ShippingCost` tulajdonosa pontosan egy a kamion és a szállítólevél közül. Mindkét tábla **plugin-tulajdonú**,
a klienssel nem megosztott. Részletek: [`MARGIN/README.md`](MARGIN/README.md).
## FullProcessModel
Container for bulk data sync between server and FruitBankHybridApp via SignalR:
@@ -0,0 +1,67 @@
# ÁRRÉS — ISSUES
> Companion to [`README.md`](README.md). Topic `MARGIN`, prefix `MGFBANKPLUG` → entry IDs `MGFBANKPLUG-MARGIN-<I|B>-<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-MARGIN-B-T4M6: A `fbShippingDocument.FxRate` alapértéke 1, ezért a régi devizás beszerzés forintként számol
**Status:** Open · **Priority:** P2 · **Type:** B (séma-defekt, éles adaton mérve) · **2026-08-14**
A séma-bevezetéskor (`MARGIN_schema.sql`) az `FxRate` oszlop `NOT NULL CONSTRAINT ... DEFAULT (1)` értéket kapott,
a `CurrencyCode` pedig `NULL` maradt. Így **minden, a funkció előtt keletkezett szállítólevél 1-es árfolyammal
szerepel**, függetlenül attól, milyen devizában érkezett.
**Éles mérés (2026-08-14):** az `A6513` dokumentum (2026-06-13, EUR-s beszállító) 1250 EUR tétel-értéke
1120 rekeszre osztva 19 rekesz eladásánál **21 Ft** önköltséget adott — a helyes érték a 356-os árfolyammal
ennek 356-szorosa. Az árrés így 98,69% volt a valós helyett.
**A hiba iránya mindig ugyanaz:** az önköltség a valós töredéke, tehát a nyereség irreálisan magasnak látszik —
ez a rosszabb irány, mert nem tűnik hibának.
**Ellentmondás a kódon belül:** a `ShippingCostService.ResolveFxRate` szándékosan **0**-t ad vissza hiányzó
árfolyamnál, épp azért, hogy ne vegyünk forintnak egy eurót. Az oszlop alapértéke ezzel szembement.
### Enyhítés (kész, 2026-08-14)
A riport jelzi az érintett sorokat: `MarginRow.HasUntrustworthyFxRate` — nincs pénznem, vagy a pénznem nem HUF
és az árfolyam ≤ 1. Sor szinten euró-ikon a tényleges értékekkel, felül összesített figyelmeztetés. A hibás szám
így nem tűnik el, de **látszik**, hogy nem megbízható.
### Megoldás iránya
1. A meglévő sorok visszatöltése a partner devizájából és a config árfolyamából. ⚠️ A config a **mai** kulcsot
tartja, tehát a régi hónapokra ez közelítés — a pontos érték csak akkor lenne meg, ha akkor rögzítettük volna.
2. Az alapérték felülvizsgálata: a `DEFAULT (1)` csak akkor helyes, ha a pénznem is ismerten HUF. Egy `NULL`
alapértelmezés (vagy 0) a hiányt hiányként ábrázolná, összhangban a `ResolveFxRate` viselkedésével.
### Frissítés (2026-08-14) — az árfolyam forrása lecserélve
Az eredeti megvalósítás az EKÁER config-értékét (`Ekaer:ExchangeRate:EurHuf`) használta az önköltséghez. Ez
**téves újrahasználás volt**: azt az árfolyamot a NAV írja elő, fix éves értékként, a bejelentéshez — nem a
tényleges átváltás. Az önköltség azóta a nopCommerce saját pénznem-nyilvántartásából számol
(`ICurrencyService`, `Currency.Rate`), az EKÁER pedig változatlanul a sajátjából. A két forrás **szándékosan
külön marad**; az indoklás a [`README.md`](README.md) → *Két árfolyam-forrás van* szakaszában.
### Frissítés (2026-08-14) — a valódi rés a SignalR-es út volt, nem a régi sorok
A fenti *Megoldás iránya* 1. pontja (a meglévő sorok visszatöltése) **elvetve**: az ADR 0002-R7Q2 11. pontja
szerint tiszta lappal indulunk, visszamenőleges feltöltés nélkül. A régi rétegek kifutnak; addig a riport jelöli
őket (hiányzó beszerzési érték, nem megbízható árfolyam). Ezt nem javítjuk, hanem **láthatóvá tesszük**.
Ami viszont valódi, ma is keletkező hiány volt: a **`FruitBankDataController.AddShippingDocument`** (SignalR, a
Hybrid appból) egyáltalán nem töltötte ki a pénznemet és az árfolyamot, tehát az így létrejövő dokumentumok is az
oszlop `DEFAULT (1)` értékét kapták. ✅ **Javítva (2026-08-14):** mindkét dokumentum-létrehozó út a közös
`ShippingCostService.ResolveDocumentCurrencyAsync`-et hívja.
> **Note:** Ez a bejegyzés innentől **elfogadott jelenlegi viselkedést** dokumentál: a funkció előtti
> dokumentumok árfolyama 1 marad, és a riport jelzi őket. Nem ütemezett változás — a régi készlet kifutásával
> magától megszűnik. Nyitva marad viszont a séma alapértéke (a *Megoldás iránya* 2. pontja): a `DEFAULT (1)` egy
> jövőbeli, harmadik dokumentum-létrehozó úton ugyanezt a hibát okozná.
**Affected:**
- `docs/MARGIN/MARGIN_schema.sql` — az oszlop alapértéke
- `Services/ShippingCostService.cs` — az árfolyam forrása (2026-08-14 óta a nop pénznem-nyilvántartása)
- `Services/MarginCalculationService.cs``HasUntrustworthyFxRate` (enyhítés)
- `Areas/Admin/Views/MarginReport/Index.cshtml` — a jelzés
@@ -0,0 +1,88 @@
# ÁRRÉS — TODO
> Companion to [`README.md`](README.md). Topic `MARGIN`, prefix `MGFBANKPLUG` → entry IDs `MGFBANKPLUG-MARGIN-T-<RAND>`.
> ID format, Status vocabulary, type codes and archival are **not restated here** — see [`TOPIC_CODES.md`](../../.github/TOPIC_CODES.md) (→ framework registry).
Scope: az önköltség- és árrés-számítás megépítése.
## Active entries
## MGFBANKPLUG-MARGIN-T-K5V9: Dokumentum-szintű nyereség-kimutatás — költség-rétegek, FIFO-fogyasztás, számított árrés
**Status:** InProgress · **Priority:** P2 · **Type:** T (adatmodell + szolgáltatás + admin riport) · **2026-08-13**
> **2026-08-14:** a lánc végigépült és éles adaton futott — séma, entitások, FIFO-hozzárendelés, költség-bevitel,
> számítás és riport. Ami hátravan, az alább pipa nélkül áll. A menet közben talált séma-defekt külön bejegyzés:
> [`MGFBANKPLUG-MARGIN-B-T4M6`](MARGIN_ISSUES.md).
Az [ADR 0002-R7Q2](../adr/0002-R7Q2-document-cost-layers-and-margin.md) **horgony-bejegyzése** — ez az entry viszi
a döntés implementációs állapotát. A döntés indoklása, alternatívái és következményei az ADR-ben; itt csak a
lépések és az állapotuk.
### Lépések
- [x] **Séma — beszerzési oldal.** `ShippingItem.NetTotalOnDocument` (nettó tétel-végösszeg a dokumentum
devizájában) és `ShippingItem.VatRate`; `ShippingDocument.CurrencyCode` + `FxRate`. Az entitások a
`FruitBank.Common/Entities/` alatt vannak → **plugin + Hybrid együtt-deploy**.
*A négy mező a `FruitMasterErp` keret-rétegébe került (`FmShippingItemBase` / `FmShippingDocumentBase`),
mert a párjuk (`UnitPriceOnDocument`) is ott van, és a fogalom nem FruitBank-specifikus.*
- [x] **Séma — költségek.** `fbShippingCost`: tulajdonos `ShippingId` **vagy** `ShippingDocumentId` (pontosan az
egyik), `CostTypeId` (fuvar / vám / rakodás / egyéb), `NetAmount`, `Currency`, `FxRate`, megnevezés.
- [x] **Séma — fogyasztás.** `fbCostConsumption` (plugin-tulajdonú, a Common-ba NEM kerül): `ShippingItemId`,
fogyasztó típusa + azonosítója, `Quantity`, `NetWeight`, `Created`, `IsManual`.
- [x] **A `VatRate` alapértelmezésének forrása** tisztázandó: a `ProductDto` ma nem hordozza a nop adókategóriát,
tehát vagy a nop `Product` entitásból olvassuk, vagy a DTO bővül.
*Megoldva másképp: a kulcs a beszállító országkódjából jön (nem-HU → 0), belföldinél az admin adja meg.
A termék adókategóriája mint forrás az áfa-analitika feature kérdése marad — az árrés végig nettóval számol.*
- [x] **Az árfolyam forrása** tisztázandó (kézi bevitel vagy külső lekérdezés). Ellenőrizendő, hogy az
`EkaerHistory.ConversionRate` körül van-e már működő megoldás.
*A meglévő EKÁER-forrás lett (`Ekaer:ExchangeRate:EurHuf`), a `ShippingCostService.ResolveFxRate`-en át;
az EKÁER-től eltérően az érték a dokumentumon TÁROLÓDIK, a bevételezéskor befagyasztva.*
- [x] **Fogyasztás-képzés** a `SetOrderStatusToCompleteAsync` tranzakciójában, a készlet-levonással egy eseményből;
FIFO a beérkezés dátuma szerint, előrendelésnél a konverziót kiváltó szállítólevélből, réteg híján
„ismeretlen forrású" jelöléssel. A leltár-zárás ugyanez, más fogyasztó-típussal.
*⚠️ Két része NEM készült el: az előrendelés pontos forrása (lásd lent) és a leltár-zárás (lásd lent).*
- [x] **Számító szolgáltatás:** fajlagos önköltség, fuvar-hányad (raklap-arányos, az adott kamionon **elfoglalt**
raklapokra osztva), extra-hányad, árrés — mind lekérdezésből, tárolt pénzügyi érték nélkül.
- [x] **Admin riport** dokumentum- és kamion-bontásban, valamint havonta (a fogyasztási sor `Created` dátuma
szerint), külön soron az „ismeretlen forrású" és a még készleten lévő rész.
- [ ] **Leltári leírás mint fogyasztó** — a `StockTakingDbContext.CloseStockTaking` még nem hív a
`CostAttributionService`-be, ezért a leírt áru bent marad a rétegben, és a riport `Maradt` oszlopa
készletnek mutatja. A `CostConsumerType.StockTakingItem` már létezik.
- [ ] **Előrendelés pontos forrása** — a `fbPreOrderItem` nem tárolja, melyik szállítólevél elégítette ki, ezért
ott is FIFO dönt. Egy oszlop + egy értékadás a `PreOrderConversionService`-ben.
- [ ] **Futásidejű ellenőrzés: devizás beszerzés önköltsége** — egy ÚJ, EUR-os beszállítótól származó
szállítólevélnél igazolni, hogy a dokumentum `FxRate`-je a nop `Currency` táblájából jön (≈357), és hogy az
önköltség a várt nagyságrendbe esik. A régi dokumentumokat **nem** töltjük vissza (ADR 11. pont).
- [ ] **Az allocation-backfill állapotának ellenőrzése** — a fuvar-szétosztás az allocationökre épül; ha egy régi
szállítmánynál nincs allocation, a fuvarköltség némán nulla. A
[`SHPLAN/README.md`](../../../../../../FruitBankHybridApp/docs/SHPLAN/README.md) P0/P1 sorai a backfillt még
nyitottként jelölik, a P3P7 viszont kész — a jelzés állapota ellenőrizetlen.
- [ ] **A `FruitBankDataController.cs:1360` néma no-op sorsa** (beállítja a `ProductCost`-ot, de nem menti):
törlés vagy javítás, a nop saját funkcióinak igénye szerint.
- [x] **Docs, ahogy a részek landolnak:** [`README.md`](README.md) átírása jelen-állapotra, a
[`SCHEMA.md`](../SCHEMA.md) újragenerálása, a [`STOCK/README.md`](../STOCK/README.md) kiegészítése azzal,
hogy a készlet-mozgás költség-réteget is fogyaszt, és a [`DOMAIN_MODEL.md`](../DOMAIN_MODEL.md)
entitás-hierarchiája az új táblákkal.
*A `README.md`, a `STOCK/README.md` és a `DOMAIN_MODEL.md` átvezetve (2026-08-14). A `SCHEMA.md` NEM —
lásd a következő pontot.*
- [ ] **Szabadon állítható historikus árfolyam** (későbbre halasztva, Adam 2026-08-14). Ma az árfolyam a
**rögzítés** pillanatában fagy be: a szállítólevélnél a bevételezéskor, a költség-sornál a bevitel napján —
utóbbi egy késve érkező fuvarszámlánál gyakorlatilag véletlen dátum. A kézenfekvő javítás (a számla kelte
szerinti árfolyam) **nem elég**: az önköltségnek a **kifizetés** napjához kell igazodnia, azt viszont ma nem
tartjuk nyilván. Ezért nem a dátum-forrást kell cserélni, hanem historikus árfolyam-lekérdezés kell,
szabadon megadható dátummal. Addig a mostani viselkedés vállalt: a fuvar aránya kicsi az önköltségben.
- [ ] **`SCHEMA.md` újragenerálása.** A fájl generált (kézzel nem szerkeszthető), és a generátor a
`FullProcessModel` gyökereit járja be — a két új, plugin-tulajdonú entitás nincs benne. Előbb a seedbe
kell felvenni őket, utána futtatható a generálás.
### Affected
- `FruitBank.Common/Entities/{ShippingItem,ShippingDocument}.cs` — az új mezők (wire-kontraktus)
- `Domains/DataLayer/` (plugin) — `fbShippingCost` / `fbCostConsumption` entitás, DbTable, DbSet-interfész,
`FruitBankDbContext`, `Mapping/Builders/PluginBuilder.cs`, `Mapping/NameCompatibility.cs`
- `FruitBankDbContext.SetOrderStatusToCompleteAsync` / `UpdateStockQuantityAndWeightAsync` — a fogyasztás-képzés
csatolási pontja
- `PreOrderConversionService` — a konverzióból származó tételek forrás-dokumentuma
- `StockTakingDbContext.CloseStockTaking` — a leltári leírás mint fogyasztó
- `Areas/Admin/` — a riport felülete
@@ -0,0 +1,161 @@
/* =============================================================================
ÁRRÉS (MARGIN) séma, ADR 0002-R7Q2
-----------------------------------------------------------------------------
Két új tábla (fbShippingCost, fbCostConsumption) + négy oszlop meglévő
táblákon. 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(18,6) (árfolyam)
Súly = float (a szomszédos fb táblák konvenciója)
============================================================================= */
USE FruitBank_DEV; -- PROD-hoz írd át a PROD adatbázis nevére!
SET NOCOUNT ON;
GO
/* -----------------------------------------------------------------------------
1) fbShippingItem a beszerzés nettó tétel-végösszege és áfakulcsa
-----------------------------------------------------------------------------
NetTotalOnDocument: a tétel NETTÓ vételára a dokumentum devizájában.
A név szándékosan hordozza a mértékegységet (mint a NetWeightOnDocument /
GrossWeightOnDocument) bruttót ebbe az oszlopba írni hiba.
VatRate: a beszerzés áfakulcsa SZÁZALÉKBAN (27.00 = 27%). Közösségi
beszerzésnél 0. A meglévő UnitPriceOnDocument NEM változik azon az
EKÁER értékszámítása ül.
----------------------------------------------------------------------------- */
IF COL_LENGTH('dbo.fbShippingItem', 'NetTotalOnDocument') IS NULL
ALTER TABLE dbo.fbShippingItem ADD NetTotalOnDocument decimal(18,4) NOT NULL CONSTRAINT DF_fbShippingItem_NetTotalOnDocument DEFAULT (0);
GO
IF COL_LENGTH('dbo.fbShippingItem', 'VatRate') IS NULL
ALTER TABLE dbo.fbShippingItem ADD VatRate decimal(5,2) NOT NULL CONSTRAINT DF_fbShippingItem_VatRate DEFAULT (0);
GO
/* -----------------------------------------------------------------------------
2) fbShippingDocument deviza és a bevételezéskor BEFAGYASZTOTT árfolyam
-----------------------------------------------------------------------------
Egy beszerzés = egy deviza = egy árfolyam. A beérkezéskori árfolyam a helyes,
a mai nem ezért tárolt, nem számított. HUF-nál a rate 1.
----------------------------------------------------------------------------- */
IF COL_LENGTH('dbo.fbShippingDocument', 'CurrencyCode') IS NULL
ALTER TABLE dbo.fbShippingDocument ADD CurrencyCode nvarchar(3) NULL;
GO
IF COL_LENGTH('dbo.fbShippingDocument', 'FxRate') IS NULL
ALTER TABLE dbo.fbShippingDocument ADD FxRate decimal(18,6) NOT NULL CONSTRAINT DF_fbShippingDocument_FxRate DEFAULT (1);
GO
/* -----------------------------------------------------------------------------
3) fbShippingCost fuvardíj és egyéb szállítmány-költségek
-----------------------------------------------------------------------------
Egy tábla két tulajdonossal: a költség VAGY a kamionhoz (ShippingId), VAGY a
szállítólevélhez (ShippingDocumentId) tartozik pontosan az egyikhez.
A fuvardíj is csak egy sor itt, CostTypeId = 10; a UI megjelenítheti a
kamionnál dedikált mezőként.
CostTypeId: 10 = fuvar · 20 = vám · 30 = rakodás · 99 = egyéb
NetAmount: NETTÓ összeg a sor saját devizájában
----------------------------------------------------------------------------- */
IF OBJECT_ID('dbo.fbShippingCost', 'U') IS NULL
BEGIN
CREATE TABLE dbo.fbShippingCost
(
Id int IDENTITY(1,1) NOT NULL,
ShippingId int NULL,
ShippingDocumentId int NULL,
CostTypeId int NOT NULL,
Name nvarchar(400) NULL,
NetAmount decimal(18,4) NOT NULL CONSTRAINT DF_fbShippingCost_NetAmount DEFAULT (0),
CurrencyCode nvarchar(3) NULL,
FxRate decimal(18,6) NOT NULL CONSTRAINT DF_fbShippingCost_FxRate DEFAULT (1),
VatRate decimal(5,2) NOT NULL CONSTRAINT DF_fbShippingCost_VatRate DEFAULT (0),
Created datetime2(7) NOT NULL CONSTRAINT DF_fbShippingCost_Created DEFAULT (SYSUTCDATETIME()),
CreatorId int NULL,
Modified datetime2(7) NOT NULL CONSTRAINT DF_fbShippingCost_Modified DEFAULT (SYSUTCDATETIME()),
ModifierId int NULL,
CONSTRAINT PK_fbShippingCost PRIMARY KEY CLUSTERED (Id),
/* pontosan az egyik tulajdonos */
CONSTRAINT CK_fbShippingCost_OneOwner CHECK
((CASE WHEN ShippingId IS NULL THEN 0 ELSE 1 END) +
(CASE WHEN ShippingDocumentId IS NULL THEN 0 ELSE 1 END) = 1),
CONSTRAINT CK_fbShippingCost_NetAmount CHECK (NetAmount >= 0),
CONSTRAINT CK_fbShippingCost_FxRate CHECK (FxRate > 0),
CONSTRAINT CK_fbShippingCost_VatRate CHECK (VatRate >= 0),
/* a költség a tulajdonosával együtt megy — nélküle értelmezhetetlen */
CONSTRAINT FK_fbShippingCost_Shipping FOREIGN KEY (ShippingId)
REFERENCES dbo.fbShipping (Id) ON DELETE CASCADE,
CONSTRAINT FK_fbShippingCost_ShippingDocument FOREIGN KEY (ShippingDocumentId)
REFERENCES dbo.fbShippingDocument (Id) ON DELETE CASCADE
);
CREATE INDEX IX_fbShippingCost_ShippingId ON dbo.fbShippingCost (ShippingId) WHERE ShippingId IS NOT NULL;
CREATE INDEX IX_fbShippingCost_ShippingDocumentId ON dbo.fbShippingCost (ShippingDocumentId) WHERE ShippingDocumentId IS NOT NULL;
END
GO
/* -----------------------------------------------------------------------------
4) fbCostConsumption melyik beszerzésből mennyi fogyott
-----------------------------------------------------------------------------
A modell EGYETLEN tárolt adata. Fajlagos önköltség, fuvar-hányad, árrés:
mind lekérdezés így a később érkező helyesbített számla visszamenőleg átüt.
ShippingItemId NULL = "ismeretlen forrású" fogyasztás: induláskor a készleten
álló áruhoz nincs réteg. A riportnak KÜLÖN soron kell mutatnia; nulla
önköltséggel beolvasztani hamis nyereséget adna.
ConsumerTypeId: 10 = OrderItem (eladás) · 20 = StockTakingItem (leltári leírás)
Quantity = rekesz (nem mérendő terméknél ez a fogyasztás mértéke)
NetWeight = kg (mérendő terméknél ez a fogyasztás mértéke)
----------------------------------------------------------------------------- */
IF OBJECT_ID('dbo.fbCostConsumption', 'U') IS NULL
BEGIN
CREATE TABLE dbo.fbCostConsumption
(
Id int IDENTITY(1,1) NOT NULL,
ShippingItemId int NULL,
ConsumerTypeId int NOT NULL,
ConsumerId int NOT NULL,
Quantity int NOT NULL CONSTRAINT DF_fbCostConsumption_Quantity DEFAULT (0),
NetWeight float NOT NULL CONSTRAINT DF_fbCostConsumption_NetWeight DEFAULT (0),
IsManual bit NOT NULL CONSTRAINT DF_fbCostConsumption_IsManual DEFAULT (0),
Created datetime2(7) NOT NULL CONSTRAINT DF_fbCostConsumption_Created DEFAULT (SYSUTCDATETIME()),
CreatorId int NULL,
Modified datetime2(7) NOT NULL CONSTRAINT DF_fbCostConsumption_Modified DEFAULT (SYSUTCDATETIME()),
ModifierId int NULL,
CONSTRAINT PK_fbCostConsumption PRIMARY KEY CLUSTERED (Id),
CONSTRAINT CK_fbCostConsumption_Quantity CHECK (Quantity >= 0),
CONSTRAINT CK_fbCostConsumption_NetWeight CHECK (NetWeight >= 0),
/* A tétel törlése NEM törli az eladás-előzményt: a sor "ismeretlen
forrásúvá" degradálódik. Így a meglévő kaszkád-törlés (Shipping →
dokumentum tétel) sem akad el egy FK-n. */
CONSTRAINT FK_fbCostConsumption_ShippingItem FOREIGN KEY (ShippingItemId)
REFERENCES dbo.fbShippingItem (Id) ON DELETE SET NULL
);
/* réteg-egyenleg: mennyi fogyott egy beszerzésből */
CREATE INDEX IX_fbCostConsumption_ShippingItemId ON dbo.fbCostConsumption (ShippingItemId) INCLUDE (Quantity, NetWeight);
/* visszafelé: egy rendelés-tétel miből lett kiszolgálva */
CREATE INDEX IX_fbCostConsumption_Consumer ON dbo.fbCostConsumption (ConsumerTypeId, ConsumerId);
/* havi bontás */
CREATE INDEX IX_fbCostConsumption_Created ON dbo.fbCostConsumption (Created);
END
GO
/* -----------------------------------------------------------------------------
Ellenőrzés
----------------------------------------------------------------------------- */
SELECT 'fbShippingItem.NetTotalOnDocument' AS Objektum, CASE WHEN COL_LENGTH('dbo.fbShippingItem','NetTotalOnDocument') IS NULL THEN 'HIÁNYZIK' ELSE 'OK' END AS Allapot
UNION ALL SELECT 'fbShippingItem.VatRate', CASE WHEN COL_LENGTH('dbo.fbShippingItem','VatRate') IS NULL THEN 'HIÁNYZIK' ELSE 'OK' END
UNION ALL SELECT 'fbShippingDocument.CurrencyCode', CASE WHEN COL_LENGTH('dbo.fbShippingDocument','CurrencyCode') IS NULL THEN 'HIÁNYZIK' ELSE 'OK' END
UNION ALL SELECT 'fbShippingDocument.FxRate', CASE WHEN COL_LENGTH('dbo.fbShippingDocument','FxRate') IS NULL THEN 'HIÁNYZIK' ELSE 'OK' END
UNION ALL SELECT 'fbShippingCost', CASE WHEN OBJECT_ID('dbo.fbShippingCost','U') IS NULL THEN 'HIÁNYZIK' ELSE 'OK' END
UNION ALL SELECT 'fbCostConsumption', CASE WHEN OBJECT_ID('dbo.fbCostConsumption','U') IS NULL THEN 'HIÁNYZIK' ELSE 'OK' END;
GO
@@ -0,0 +1,91 @@
# ÁRRÉS — beszerzési önköltség és nyereség-kimutatás
> Topic `MARGIN` (prefix `MGFBANKPLUG`) → entry ID-k `MGFBANKPLUG-MARGIN-<I|T|B>-<RAND>`. ID-formátum/Status-szótár → [`TOPIC_CODES.md`](../../.github/TOPIC_CODES.md) (→ keret-registry).
> Companions: [`MARGIN_TODO.md`](MARGIN_TODO.md) — nyitott munka · [`MARGIN_ISSUES.md`](MARGIN_ISSUES.md) — ismert defektek.
A kiadott áru hozzá van rendelve ahhoz a beszerzéshez, amiből származik, és ebből áll elő az árrés
szállítólevél, kamion és hónap bontásban. Séma: [`MARGIN_schema.sql`](MARGIN_schema.sql).
## Az elszámolás egysége
A **szállítólevél-tétel** (`ShippingItem`) egy költség-réteg. A kamion nem tárolt kapcsolat: a tétel árrése a
`ShippingItemToShipping` allocationökön át, raklap-arányosan oszlik a kamionjai között.
## A hozzárendelés
A rendelés lezárásakor (`FruitBankDbContext.SetOrderStatusToCompleteAsync`) a `CostAttributionService`
**FIFO** szerint osztja szét a kiadott árut a termék rétegei között, a szállítólevél dátuma szerint a
legrégebbitől. Ugyanabban a tranzakcióban fut, mint a készlet-levonás.
- A fogyasztás **vezérlő** mértékegysége terméktípus-függő, és követi az árazást: mérendőnél **kg**,
nem mérendőnél **rekesz**. A sor a másik dimenziót is kitölti a tétel arányában.
- Réteg híján a sor **ismeretlen forrású** (`ShippingItemId = null`), és naplóba is kerül. Nulla önköltséggel
beolvasztani tilos — a riport külön során jelenik meg.
- Idempotens: ha egy rendelés-tételhez már van fogyasztás, nem keletkezik újabb.
Tárolt adat egyedül az elfogyasztott mennyiség (`fbCostConsumption`). Minden pénzügyi érték lekérdezés, ezért
egy később érkező helyesbített beszállítói vagy fuvarszámla **visszamenőleg átüt** a riporton.
## A számítás
| Elem | Képlet |
|---|---|
| fajlagos önköltség | `ShippingItem.NetTotalOnDocument × ShippingDocument.FxRate / MÉRT mennyiség` |
| áru-önköltség | fajlagos × eladott |
| fuvar-hányad | `Σ_kamion (kamion költségei × allocation.Pallets / kamion összes raklapja)`, az eladott arányra szűkítve |
| extra-hányad | `dokumentum költségei × tétel raklapja / dokumentum összes raklapja`, az eladott arányra szűkítve |
| árbevétel | `Σ eladott mennyiség × OrderItemDto.UnitPriceExclTax` |
| árrés | árbevétel áru-önköltség fuvar extra |
Minden érték **nettó** és **HUF**. Az osztó a kamionon ténylegesen elfoglalt raklapok száma, nem a kapacitás:
egy félig pakolt kamion drágábbá teszi az árut. A gyűjtőjárat (`Shipping.IsPartial`) nem kap külön ágat — ott a
díj eleve csak a saját részünkre szól.
A fuvar és az extra a **teljes tételre** szól, de csak az eladott hányada költség; a maradék a készlet értékében
ül. A `MarginRow.Remaining` mutatja, mennyi van még a rétegben.
## A beszerzési érték útja
| Lépés | Hol |
|---|---|
| Az AI kiolvassa a nettó tétel-végösszeget (`netTotal`) | `OpenAIApiService` prompt → `FileManagerController` |
| Hiányzó sor-végösszegnél tartalék: `egységár × mennyiség` | `FileManagerController.ResolveNetTotalOnDocument` |
| Az admin ellenőrzi/javítja | `Areas/Admin/Views/Extras/ImageTextExtraction.cshtml` |
| Áfakulcs a beszállító országkódjából (nem-HU → 0) | `FileManagerController.IsDomesticPartner` |
| Deviza a partnerről, árfolyam a bevételezéskor befagyasztva | `ShippingCostService.ResolveFxRateAsync` |
Az áfakulcsot **szándékosan nem az AI adja**: levezethető, és két egymásnak ellentmondó forrás rosszabb, mint egy.
### Két árfolyam-forrás van, és ez így helyes
| Cél | Forrás | Miért |
|---|---|---|
| Önköltség, árrés | nopCommerce `Currency.Rate` (`ICurrencyService`, EKB-provider + `UpdateExchangeRateTask`) | számviteli szám: a **tényleges** átváltás felel meg neki |
| EKÁER-bejelentés | appsettings `Ekaer:ExchangeRate:EurHuf` | jogi artefaktum: a NAV **fix, általa megadott éves** árfolyamot követeli |
Ugyanahhoz a szállítólevélhez ezért **jogosan tartozhat két különböző forintérték**. A kettő „egységesítése"
kézenfekvőnek tűnik, de hibás lenne: az egyik oldal a jogszabályt sértené, a másik valótlan önköltséget adna.
Ismeretlen pénznemnél az árfolyam **0**, nem 1: az 1-es szorzó forintnak venné a devizát, és az így keletkező hiba
mindig ugyanabba az irányba visz — az önköltség töredékére, az árrés felfelé
([`MGFBANKPLUG-MARGIN-B-T4M6`](MARGIN_ISSUES.md)). Belföldi partnernél a hiányzó pénznem forintra egészül ki,
mert ott az 1 valóban helyes.
## Költségek
`fbShippingCost`: a tulajdonos **vagy** a kamion (fuvardíj), **vagy** a szállítólevél (vám, rakodás) — pontosan az
egyik, DB-oldali CHECK őrzi. Az összeg mindig nettó, a saját devizájában, rögzítéskor befagyasztott árfolyammal.
Felület: admin → **Szállítmány-költségek**.
## Riport
Admin → **Árrés-kimutatás**. Az időszak az **eladás** dátumára szűr (a rendelés lezárása), nem a beérkezésére.
Bontás szállítólevél, kamion vagy hónap szerint. Külön jelöli az ismeretlen forrású sorokat, a beszerzési érték
nélküli tételeket, a kamionhoz nem rendelt tételeket és a nem megbízható árfolyamot.
## Kapcsolódó
- A döntés és alternatívái: [ADR 0002-R7Q2](../adr/0002-R7Q2-document-cost-layers-and-margin.md).
- Tétel↔kamion allocation: [FBANKAPP ADR 0003](../../../../../../FruitBankHybridApp/docs/adr/0003-shipping-item-to-shipping-mapping.md) ·
tört raklapszám: [FBANKAPP ADR 0004-M9F3](../../../../../../FruitBankHybridApp/docs/adr/0004-M9F3-fractional-pallets-and-remainder-grouping.md).
- Készlet-mozgatás: [`STOCK/README.md`](../STOCK/README.md) · mérési folyamatok: [`MEASUREMENT.md`](../MEASUREMENT.md).
@@ -0,0 +1,51 @@
# TERMÉK-TÖRZSADAT — TODO
> Companion to [`README.md`](README.md). Topic `PMDATA`, prefix `MGFBANKPLUG` → entry IDs `MGFBANKPLUG-PMDATA-T-<RAND>`.
> ID format, Status vocabulary, type codes and archival are **not restated here** — see [`TOPIC_CODES.md`](../../.github/TOPIC_CODES.md) (→ framework registry).
Scope: a mérési/logisztikai törzsadat tárolása, írási útjai és szerkesztő felülete.
## Active entries
## MGFBANKPLUG-PMDATA-T-H3N8: Tipizált törzsadat-tábla a `GenericAttribute`-ok helyett + tömeges szerkesztő
**Status:** Open · **Priority:** P2 · **Type:** T (adatmodell + szolgáltatás + admin felület) · **2026-08-13**
Az [ADR 0001-V4T7](../adr/0001-V4T7-product-master-data-storage.md) **horgony-bejegyzése** — ez az entry viszi a
döntés implementációs állapotát. Az ADR `Status`-a `Proposed`: a tárolás-váltás **ratifikálásra vár**, a szerkesztő
felület viszont az ADR 6. pontja szerint nem függ tőle.
### Lépések
- [ ] **Az ADR ratifikálása** (`Proposed``Accepted`) — a tárolás-váltás előfeltétele. A szerkesztő nem blokkolt.
- [ ] **`ProductMasterDataService`** (plugin `Services/`): egyetlen validált írási út, hibát visszaadva, nem naplóba
nyelve. Az első változata még `GenericAttribute`-ra ír, hogy a felület egyszer készüljön el.
- [ ] **Tömeges szerkesztő felület** a szolgáltatás ellen: 100-anként lapozva, sor-szintű mentéssel, név/cikkszám
kereséssel és „csak hiányos törzsadatú" szűrővel. Minta: `Areas/Admin/Views/Order/FruitBankOrderList.cshtml`.
A felületnek jeleznie kell, hogy az `AverageWeight` mérendő terméknél a következő bemérésig él.
- [ ] **`ProductMasterData` entitás + `fbProductMasterData` tábla** (1:1 a `Product`-tal), minden oszlop
`NULL`-ozható („nincs beállítva"), `CHECK` a nem-negatív értékekre.
- [ ] **Migráció:** visszatöltés a meglévő `GenericAttribute` sorokból → kettős-olvasású átmenet → a régi sorok
törlése. A DB-változás kézi SQL-lel, a szomszédos `fb…` táblák gyakorlata szerint.
- [ ] **`ProductDto` átállítása** a `ProductMasterData` asszociációra a `GenericAttributes` helyett — a DTO
`[AcBinarySerializable]` wire-kontraktus, tehát **plugin + Hybrid együtt-deploy**.
- [ ] **A `SaveProductCustomAttributesAsync` form-kaparásának kivezetése**, a `ProductAttributes` view-component
mentési útjával együtt. A `NetWeight` / `IncomingQuantity` ágak ugyanabban a metódusban laknak, de
`GenericAttribute`-on maradnak.
- [x] **Az átlagsúly 0-ra írása javítva** (2026-08-11): korai kilépés érvénytelen raklapnál, tehát a mérés nem
nullázza a termék tanult átlagsúlyát. A `//TODO ELSE` (súly-deriváció a dokumentumból érvénytelen mérésnél)
nyitva marad, de az a mérési lánc kérdése.
- [ ] **Docs, ahogy a részek landolnak:** a [`MEASUREMENT.md`](../MEASUREMENT.md) „Product `AverageWeight` updated
from measured data" sora pontosítandó azzal, hogy ez csak mérendő termékre áll; a
[`SCHEMA.md`](../SCHEMA.md) és a [`DOMAIN_MODEL.md`](../DOMAIN_MODEL.md) táblái a migráció után
frissítendők (a `DOMAIN_MODEL.md` „Product attributes" táblája a `CratesPerPallet`-et ma nem sorolja fel);
a [`README.md`](README.md) tárolás-leírása átírandó.
### Affected
- `Services/FruitBankAttributeService.cs` (plugin) — a mai írási út
- `Domains/EventConsumers/FruitBankEventConsumer.cs``SaveProductCustomAttributesAsync` form-kaparás
- `Services/MeasurementService.cs``CalculateAndSetAverageWeight` visszatanulás
- `FruitBank.Common/Dtos/ProductDto.cs` — az öt számított property forrása (wire-kontraktus)
- `Domains/DataLayer/`, `Mapping/Builders/PluginBuilder.cs`, `Mapping/NameCompatibility.cs` — az új tábla
- `Areas/Admin/` — a tömeges szerkesztő felület
@@ -0,0 +1,50 @@
# TERMÉK-TÖRZSADAT — mérési és logisztikai törzsadat
> Topic `PMDATA` (prefix `MGFBANKPLUG`) → entry ID-k `MGFBANKPLUG-PMDATA-<I|T|B>-<RAND>`. ID-formátum/Status-szótár → [`TOPIC_CODES.md`](../../.github/TOPIC_CODES.md) (→ keret-registry).
> Companion: [`PMDATA_TODO.md`](PMDATA_TODO.md) — a nyitott munka.
Az öt érték, amit **mi** vezettünk be a nopCommerce `Product` mellé, és amiből a mérés, a kapacitás-számítás,
az EKÁER-bejelentés és a számlázott összeg dolgozik.
## Az öt érték
| Kulcs | Típus | Jelentés |
|---|---|---|
| `IsMeasurable` | bool | mérendő-e a termék — ez választja az árazási ágat és a súly-derivációt |
| `Tare` | double | egy rekesz tárasúlya |
| `AverageWeight` | double | várt nettó átlagsúly rekeszenként |
| `AverageWeightTreshold` | double | megengedett eltérés %-ban, a mérés érvényesség-kapuja |
| `CratesPerPallet` | int | rekesz/raklap arány, amiből a raklapszám származik |
Mind az öt `GenericAttribute` a `Product`-on (`KeyGroup = "Product"`), szövegként tárolva, invariáns kultúrával
olvasva. Ugyanezen a `KeyGroup`-on két **készlet-vezérelt** érték is ül — `NetWeight` és `IncomingQuantity` —,
azok nem ide tartoznak: lásd [`STOCK/README.md`](../STOCK/README.md) és [`PREORDER/README.md`](../PREORDER/README.md).
## Írási utak
- **Kézi:** a nopCommerce termék-szerkesztő. Az értékeket a `FruitBankEventConsumer.SaveProductCustomAttributesAsync`
az `EntityUpdatedEvent<Product>` kezelése közben a `HttpContext.Request.Form`-ból olvassa ki — tehát ha nincs
nop-form POST (import, API, háttérjob), a mentés **némán elmarad**.
- **Rendszer:** az `AverageWeight`-et a `MeasurementService.CalculateAndSetAverageWeight` minden beérkezési
raklap-mentés után visszatanulja a tétel mért raklapjaiból — **csak mérendő terméknél**. Nem mérendő terméknél
a kézzel megadott átlagsúly tartós, és a mérés soha nem írja felül.
A másik négy értéknek nincs konkurens rendszer-írója.
## Validálás
A `GetValueOrDefault<T>` a hiányzó sorra `default`-ot ad, tehát a **hiányzó** és a **0** érték a DTO-olvasókban
egybeesik; a hiányzó `IsMeasurable` futásidőben `false`. Az elgépelt vagy hiányzó törzsadat így nem hibát okoz,
hanem rossz számot — EKÁER-bejelentésben és számlán egyaránt.
Az SQL-oldali, tömeges ellenőrzés (hiányzó/érvénytelen érték, név↔érték ütközés, irreális értékek, összesítő)
és a hozzá tartozó migrációs runbook: [`PRODUCT_WEIGHT_DATA_DB_VALIDATION.md`](../PRODUCT_WEIGHT_DATA_DB_VALIDATION.md).
**Tömeges szerkesztő felület ma nincs** — a javítás terméknként, a nop admin termék-szerkesztőjén át történik.
## Kapcsolódó
- A tárolás jövőjének döntése és alternatívái: [ADR 0001-V4T7](../adr/0001-V4T7-product-master-data-storage.md).
- A `CratesPerPallet` fogyasztója: [FBANKAPP ADR 0004-M9F3](../../../../../../FruitBankHybridApp/docs/adr/0004-M9F3-fractional-pallets-and-remainder-grouping.md) ·
a `Tare` + `AverageWeight` fogyasztója: [FBANKAPP ADR 0001](../../../../../../FruitBankHybridApp/docs/adr/0001-non-measurable-item-derived-weight.md).
- Mérési folyamatok: [`MEASUREMENT.md`](../MEASUREMENT.md) · EKÁER: [`EKAER/README.md`](../EKAER/README.md).
+2
View File
@@ -22,6 +22,8 @@ Topic documentation for the FruitBank-specific NopCommerce plugin (Layer 2 — c
- [`ORDERDRAFT/`](ORDERDRAFT/README.md) — AI-assisted intake: free-text messages parsed into order drafts, admin-approved into pre-orders (+ `ORDERDRAFT_ISSUES.md`)
- [`EKAER/`](EKAER/README.md) — NAV EKÁER reporting, server side: obligation gate, `Shipping`/`Order``tradeCard` mapping, submission (+ `EKAER_ISSUES.md` / `EKAER_TODO.md`)
- [`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`)
- [`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
@@ -19,6 +19,11 @@ Ez tudatos, jelenleg nyitott üzleti döntés — lásd
A mechanika (metódusok, hívási sorrend) a [`DATA_LAYER.md`](../DATA_LAYER.md)-ben; a mérési oldal a
[`MEASUREMENT.md`](../MEASUREMENT.md)-ben.
**A rendelés-lezárás készlet-mozgása költség-réteget is fogyaszt:** ugyanabban a tranzakcióban a
`CostAttributionService` FIFO szerint hozzárendeli a kiadott árut ahhoz a beszerzéshez, amiből származik
(`fbCostConsumption`) — ebből áll elő az árrés. Lásd [`MARGIN/README.md`](../MARGIN/README.md).
A **leltár-zárás** ezt még nem teszi meg (`MGFBANKPLUG-MARGIN-T-K5V9`).
## Nyitott munka
- [`STOCK_ISSUES.md`](STOCK_ISSUES.md)
@@ -258,4 +258,5 @@ hogy a UI a váltáskor ne íródjon újra.**
[`FruitBankEventConsumer.SaveProductCustomAttributesAsync`](../../Domains/EventConsumers/FruitBankEventConsumer.cs) ·
[`GenericAttributeExtensions.cs`](../../../../Libraries/Mango.Nop.Core/Extensions/GenericAttributeExtensions.cs) ·
`FruitBank.Common/Dtos/ProductDto.cs`
- Tracking entry: **még nincs** — lásd a `Follow-ups` első pontját (topik-kód regisztrálása szükséges).
- Tracking entry: `MGFBANKPLUG-PMDATA-T-H3N8` ([`PMDATA_TODO.md`](../PMDATA/PMDATA_TODO.md)) — a `PMDATA` topik
regisztrálva 2026-08-13-án.
@@ -0,0 +1,237 @@
# ADR 0002-R7Q2: A nyereség-kimutatás a szállítólevél szintjén számol, FIFO-val fogyasztott költség-rétegekből, és minden pénzügyi értéket lekérdezéssel állít elő
## Status
Proposed (2026-08-13) — a döntési pontok Adammel folytatott interjúban születtek (2026-08-13); a rekord a
ratifikálásig `Proposed`, implementáció még nem indult.
## Context
- **Kiváltó igény (Adam, 2026-08-13):** legyen kimutatás arról, hogy egy adott szállítmányból származó áru milyen
árréssel kelt el. Az alapegység a **szállítólevél**, kamionra összegezve; a megjelenítés szállítmányonként és
**havi bontásban**, az adminban.
- **A beszerzési ár rekesz-egységár.** A `UnitPriceOnDocument` sorértéke a mennyiséggel szorzódik
([`FmEkaerValueCalculator.ItemLineValue`](../../../../../../FruitMasterErp/FruitMasterErp.Core/Services/Ekaer/FmEkaerValueCalculator.cs):
`item.UnitPriceOnDocument * item.QuantityOnDocument`). A dokumentumon Adam szerint **tételenként végösszeg is**
szerepel, amiből a fajlagos érték visszaosztható.
- **Az eladási ár mértékegysége terméktípus-függő.** A `CustomPriceCalculationService` mérendő terméknél
`NetWeight × UnitPriceExclTax`, nem mérendőnél `Quantity × UnitPriceExclTax` ([`MEASUREMENT.md`](../MEASUREMENT.md)
*CustomPriceCalculationService*). A beszerzési és az eladási oldal tehát **más dimenzióban** áll; az összekötő
a bevételezéskor mért mennyiség.
- **Ma nincs használható önköltség-előzmény.**
[`FruitBankDataController.cs:1360`](../../Controllers/FruitBankDataController.cs) az `AddShippingDocument`
ciklusában beállítja a `product.ProductCost`-ot a tétel dokumentum-egységárára, de a lekért entitást
**nem menti** (nincs `UpdateAsync` a ciklusban) — az írás elvész. Ha mentene, akkor is „utolsó ár nyer" volna,
előzmény nélkül. A nop admin `OriginalProductCost`-ja (`Nop.Web/Areas/Admin/Controllers/OrderController.cs:1692`)
nem pillanatkép, hanem **élő lekérdezés** az aktuális `Product.ProductCost`-ra. Visszamenőleges árrés tehát
a mai adatból nem áll elő.
- **A fizikai azonosítás hiányzik.** A raktáros nem jelöli, melyik raklapról szedte le a kiadott árut, tehát
a „melyik beszerzésből fogyott" kérdésre **szabály** kell, nem mérés.
- **A tétel↔kamion lánc létezik.** A `ShippingItemToShipping` allocation
([FBANKAPP ADR 0003](../../../../../../FruitBankHybridApp/docs/adr/0003-shipping-item-to-shipping-mapping.md))
bontja egy szállítólevél tételét több kamionra; a raklapszám tört érték
([FBANKAPP ADR 0004-M9F3](../../../../../../FruitBankHybridApp/docs/adr/0004-M9F3-fractional-pallets-and-remainder-grouping.md)).
A **fuvardíj a kamionhoz** tartozik, tehát egy dokumentum-tétel fuvarköltsége több kamionból is állhat.
- **Áfa-aszimmetria.** Közösségi beszerzésnél a beszállítói dokumentum áfa nélkül érkezik, belföldi
(`Partner.CountryCode == "HU"`) beszállítónál áfásan (Adam, 2026-08-13); az eladási oldal bruttó, de a nettó
készen áll (`OrderItemDto.PriceExclTax`). Bruttó–bruttó összevetésnél a külföldi szállítmányok árrése a teljes
áfa-tartalommal **túl jónak**, a belföldiek a teljes áfa-tartalommal **túl rossznak** látszanának.
- **Deviza.** `Partner.Currency` és `CargoPartner.Currency` egyaránt létezik (EUR-s beszállító és fuvarozó is van).
Az árfolyam befagyasztására van precedens a házban: `EkaerHistory.ConversionRate`.
- **Egyetlen készlet-levonási pont van.** A `FruitBankDbContext.UpdateStockQuantityAndWeightAsync` három hívóból
fut: bevételezés, rendelés-lezárás, leltár-zárás ([`STOCK/README.md`](../STOCK/README.md)).
- **Az előrendelésnél a forrás ismert.** A `PreOrderConversionService.ConvertPreOrdersForProductsAsync(productIds,
shippingDocumentId)` a konverziót egy konkrét szállítólevélhez köti ([`PREORDER/README.md`](../PREORDER/README.md)),
tehát ezeknél a tételeknél nem kell szabályra hagyatkozni.
- **Az érintett entitások megosztottak a klienssel.** A `Shipping`, `ShippingItem` és `ShippingItemToShipping`
a `FruitBank.Common/Entities/` alatt él (FruitBankHybridApp repó), és SignalR-en kimegy a raktári kliensre —
a beszerzési egységár tehát **ma is** a wire-en van, az árrés és a költség-pool viszont nincs.
- **Scope IN:** a rendelés-tétel ↔ beszerzés hozzárendelésének modellje és keletkezési pontja; a fajlagos önköltség
képzése; a fuvar és az egyéb költségek tárolása és szétosztása; a deviza- és áfakezelés az árrés szempontjából;
a riport számítási alapja (dokumentum, kamion, hónap).
**Scope OUT:** a riport felületi kialakítása; az áfa-analitika (külön feature); a Hybrid kliens bármely
megjelenítése; a számlázás (`INNVOICE.md`); a túlértékesítés kezelése; fizikai tétel-nyomonkövetés (lot/batch);
a `Product.ProductCost` további sorsa a nop saját funkcióiban.
## Decision
**A nyereség-kimutatás alapegysége a `ShippingDocument`, a költség-réteg kulcsa a `ShippingItem`; a rendelés-tételek
FIFO-val fogyasztják a rétegeket; a fogyasztott mennyiségen kívül semmilyen pénzügyi érték nem tárolódik —
minden szám lekérdezéssel áll elő, nettó alapon.**
1. **Az elszámolási egység a szállítólevél**, a költség-réteg kulcsa a `ShippingItem` (ami pontosan egy
dokumentumhoz tartozik). Kamion-szintű kimutatás **ebből összegződik** az allocationökön át, nem fordítva.
Következmény: a fogyasztási sor nem hivatkozik kamionra, tehát a séma **független** a szállítmánytervező
P4-cutoverétől és P8-átállásától — azok az allocation oldalán változnak, ahol ez a feature csak olvas.
2. **A fogyasztás szabálya FIFO**, terméknként, a beérkezés dátuma szerint, a még el nem fogyott rétegekből.
Két kivétel:
- **Előrendelésből konvertált rendelés-tétel:** a forrás a konverziót kiváltó szállítólevél, nem FIFO.
- **Nincs réteg** (indulás előtti készlet, negatív készlet): a fogyasztás **„ismeretlen forrású"**-ként
keletkezik, és a riportban **külön soron** jelenik meg. Nulla önköltséggel beolvasztani tilos — az hamis
nyereséget mutatna.
3. **A fajlagos önköltség a mért mennyiségre vetül**, mértékegységben követve az eladási oldalt:
| Termék | Eladási ár | Fajlagos önköltség |
|---|---|---|
| Mérendő | Ft/kg | nettó tételvégösszeg / **mért nettó súly** |
| Nem mérendő | Ft/rekesz | nettó tételvégösszeg / **mért mennyiség** |
Az osztó a **mért**, nem a dokumentumon szereplő mennyiség: a papír és a valóság közti hiány így automatikusan
az önköltségben van, nem külön veszteségsoron. Következmény: **a réteg fajlagos önköltsége csak a bevételezés
lemérése után áll elő** — az implementációnak ezt őriznie kell.
4. **Minden költség-bemenet nettóban tárolódik és nettóban számol.** Új mezők a `ShippingItem`-en:
- `NetTotalOnDocument` — a tétel nettó vételára a dokumentum devizájában. A név szándékosan hordozza a
mértékegységet, a szomszédos `NetWeightOnDocument` / `GrossWeightOnDocument` mintájára: egy `TotalPrice…`
nevű mezőbe előbb-utóbb bruttót írnának, és semmi nem törne el — csak az árrés lenne rossz.
- `VatRate` — a beszerzés áfakulcsa; alapértelmezése a beszállító országkódjából (`HU` → a termék nop
adókategóriájának kulcsa, egyébként 0), az admin felülírhatja. Az árréshez a nettósításhoz kell, és a későbbi
áfa-analitikának is ez a bemenete. ⚠️ A `ProductDto` ma **nem hordozza** a nop adókategóriát — az alapértelmezés
forrása az implementációkor tisztázandó (nop `Product` entitás vagy DTO-bővítés).
A meglévő `UnitPriceOnDocument` **változatlan marad**: azon az EKÁER értékszámítása ül. Ez tudatosan vállalt
párhuzamosság — a feloldása (az EKÁER átállítása a végösszegre) külön döntés, nem ezé az ADR-é.
5. **A fuvar és az egyéb költségek egy táblában laknak** (`fbShippingCost`), típuskóddal megkülönböztetve
(fuvar / vám / rakodás / egyéb). A tulajdonos **vagy** a kamion (`ShippingId`), **vagy** a szállítólevél
(`ShippingDocumentId`) — pontosan az egyik. A fuvardíj UI-on a kamionnál jelenhet meg dedikált mezőként; a
tárolás attól még egy mechanizmus, két helyett.
6. **Az osztókulcs a raklaphely, mindkét szinten**, és az osztó az **elfoglalt** raklapok száma, nem a kapacitás:
```
tétel fuvarköltsége = Σ_kamion ( fuvardíj × allocation.Pallets / Σ allocation.Pallets azon a kamionon )
```
Indok: a fuvarozó helyet ad el — egy 100 kg-os és egy 1 tonnás raklap ugyanannyi helyet foglal (Adam).
Mellékhatás, hogy a gyűjtőjárat (`Shipping.IsPartial`) **nem igényel külön ágat**: ott a díj eleve csak a mi
részünkre szól, tehát ugyanaz a képlet érvényes, és az `AvailablePallets` ki is esik a költségszámításból.
Félig pakolt kamionnál a díj kevesebb raklapra oszlik, tehát drágábbá teszi az árut — ez gazdaságilag helyes.
A dokumentum-szintű extra ugyanezzel a kulccsal oszlik a dokumentum tételei között.
7. **Egy beszerzés = egy deviza = egy árfolyam.** A `ShippingDocument` kap `CurrencyCode` és `FxRate` mezőt,
az árfolyam a bevételezéskor **befagyasztva** (precedens: `EkaerHistory.ConversionRate`). Ez az egyetlen
befagyasztott pénzügyi érték a modellben — mert a beérkezéskori árfolyam a helyes, a mai nem.
8. **Csak a fogyasztott mennyiség tárolódik.** Új, **plugin-tulajdonú** tábla (`fbCostConsumption`):
`ShippingItemId`, a fogyasztó típusa + azonosítója, `Quantity`, `NetWeight`, `Created`, `IsManual`.
Fajlagos önköltség, fuvar-hányad, extra-hányad, árrés: **mind lekérdezés**. Következmény, amit Adam kifejezetten
kért: egy később érkező helyesbített beszállítói vagy fuvarszámla **visszamenőleg átüt** a riporton, újraszámoló
futtatás nélkül.
A fogyasztó polimorf, mert a leltári leírás ugyanígy fogyaszt réteget — a mintája a házban meglévő
`EkaerHistoryMapping` (`ForeignKey` + `IsOutgoing`). ⚠️ Ott a `SCHEMA.md` külön figyelmeztet rá, hogy a két
id-tartomány átfed, tehát rossz olvasásnál nem hiba keletkezik, hanem **idegen sor** — a diszkriminátor minden
lekérdezésben kötelező.
9. **A hozzárendelés a rendelés lezárásakor keletkezik**, a `SetOrderStatusToCompleteAsync` tranzakciójában,
a készlet-levonással együtt — nem mellette, hanem ugyanabból az eseményből. A leltár-zárás ugyanezt teszi,
más fogyasztó-típussal.
10. **A havi bontás dátuma a fogyasztási sor `Created` mezője** — az a pillanat, amikor a rendelés lezárult és a
készlet mozdult. Így az eladás hónapja szerint csoportosítunk (Adam), és nem függünk a nop rendelés-dátumaitól,
amelyek közül egyik sem jelöli a lezárás idejét.
11. **Indulás tiszta lappal**, visszamenőleges feltöltés nélkül (nincs elég adat hozzá). Az „ismeretlen forrású"
fogyasztás (2. pont) az induláskor készleten álló árunál nem kivétel, hanem a **kezdeti állapot** — a riportnak
hetekig mutatnia kell, és magától fut ki.
12. **A költség- és fogyasztás-entitások plugin-oldalon maradnak**, nem a `FruitBank.Common`-ban: az árrés és a
költség-pool ne kerüljön ki a raktári kliensekre. A `ShippingItem` két új mezője (4. pont) viszont
szükségképpen a Common-ban van, ahol a beszerzési egységár ma is.
## Consequences
**Positive:**
- A dokumentum- és a kamion-szintű kimutatás **ugyanabból a modellből** adódik, két külön adatszerkezet nélkül.
- A séma **nem függ** a szállítmánytervező hátralévő fázisaitól (P4 cutover, P8 tört raklapszám) — a fogyasztási sor
nem ismer kamiont, az `allocation.Pallets` típusváltása pedig csak lekérdezést érint.
- A **helyesbítés magától átüt**: nincs befagyasztott pénzügyi érték, amit újra kellene számolni.
- Az **el nem adott rész látszik**: a réteg egyenlege (beérkezett fogyasztott) mutatja, mennyi van még készleten
és mennyi íródott le — az árrés nem tűnik jobbnak a valóságnál.
- Az előrendelésből konvertált tételeknél a hozzárendelés **egzakt**, nem szabály-alapú becslés.
- A `VatRate` a későbbi áfa-analitika bemenete is — a most felvett mező nem eldobott munka.
**Negative:**
- **A FIFO fikció.** Nem a fizikai valóságot rögzíti, hanem egy konvenciót; romlandó árunál jó közelítés, de
a kimutatás pontossága ezen a feltevésen áll.
- **Az „ismeretlen forrású" tétel az első hetekben zajos lesz**, és addig a riport csak részlegesen használható.
- **A számítás lekérdezés-alapú**, tehát minden riport-nézet újraszámol. A volumen kicsi (havi néhány ezer sor),
de az indexelés nem hagyható el.
- **A lezárt hónap száma megváltozhat** egy később érkező helyesbítéstől. Ez kért viselkedés, de a könyvelés felé
magyarázatot igényel, és kizárja a „hónapzárás = befagyasztott szám" olvasatot.
- **A bevételezés lemérése előtt nincs önköltség** (3. pont), tehát a réteg egy ideig létezik ár nélkül.
- **Wire-kontraktus:** a `ShippingItem` két új mezője a `FruitBank.Common`-ban van, tehát plugin + Hybrid
**együtt-deployt** kíván — ugyanaz a kötöttség, amit az [ADR 0001-V4T7](0001-V4T7-product-master-data-storage.md)
4. pontja is rögzít.
- **A fuvar-szétosztás az allocationök meglétén áll.** Ha egy régi szállítmánynál nincs allocation, a tétel
fuvarköltsége **némán nulla** lesz, nem hibás — ezt a riportnak jeleznie kell.
**Follow-ups required:**
- **Topik-kód regisztrálása:** a plugin [`.github/TOPIC_CODES.md`](../../.github/TOPIC_CODES.md)-jében nincs
árrés/költség topik (`PREO` / `EKAER` / `STOCK` / `ORDD` van). Javaslat: **`MARGIN`**, dokumentum-hely
`docs/MARGIN/`. Ez alá kerül az ADR **egyetlen horgony-bejegyzése** (`MGFBANKPLUG-MARGIN-T-<RAND>`).
**Adam jóváhagyásával** — ugyanaz a nyitott lépés, mint az ADR 0001-V4T7-nél.
- **Az allocation-backfill állapotának ellenőrzése.** A
[`SHPLAN/README.md`](../../../../../../FruitBankHybridApp/docs/SHPLAN/README.md) a P0-nál és a P1-nél még
nyitottként jelöli az SQL-t és a backfillt, miközben a P3P7 kész — a jelzés valószínűleg elavult, de ha nem az,
a fuvar-szétosztás a régi szállítmányoknál üres alapon dolgozna.
- **A séma-változás bevezetése** a repó bevált menete szerint: kézi SQL az üzemeltetőnél (ADR 0003 9. pont,
ADR 0004-M9F3, ADR 0001-V4T7 5. pont), nem FluentMigrator.
- **Az árfolyam forrása tisztázandó** (kézi bevitel vagy külső lekérdezés) — az `EkaerHistory.ConversionRate`
körül lehet már működő megoldás, ez az implementáció első lépésénél ellenőrizendő.
- **A `FruitBankDataController.cs:1360` néma no-op sorsa.** Ez az ADR kiváltja azt, amit az a sor célzott;
az implementációkor el kell dönteni, hogy törlődik vagy a nop saját `ProductCost` funkcióiért megjavul —
a mai állapot (beállít, de nem ment) egyik olvasat szerint sem helyes.
- **Dokumentumok, ahogy a részek landolnak** (`BASE:ARTIFACT_ROLES`): új `docs/MARGIN/README.md`;
a [`SCHEMA.md`](../SCHEMA.md) újragenerálása az új típusokkal; a [`STOCK/README.md`](../STOCK/README.md)
kiegészítése azzal, hogy a készlet-mozgás mostantól költség-réteget is fogyaszt;
a [`DOMAIN_MODEL.md`](../DOMAIN_MODEL.md) entitás-hierarchiája az új táblákkal.
## Alternatives considered
- **Mozgóátlagos önköltség terméknként** (rejected): nincs kötőtábla, nincs réteg-nyilvántartás, jóval olcsóbb —
de pont a kért „X szállítmányból Y áru" bontás vész el, mert a szállítmány identitása feloldódik az átlagban.
- **A hozzárendelés utólagos levezetése a `StockQuantityHistory`-ból, új tábla nélkül** (rejected): a mozgások
sorrendje elvben elég volna a FIFO-hoz, de a leltár-korrekciók és a kétdimenziós (darab + súly) mozgás miatt nem
egyértelmű, és nem mérendő terméknél a súly szándékosan **nem mozdul** ([`STOCK/README.md`](../STOCK/README.md)) —
a levezetés ott elhasal.
- **A költség-réteg kulcsa az allocation (`ShippingItemToShipping`) legyen a `ShippingItem` helyett** (rejected):
a fuvar hozzárendelése közvetlenebb volna, de a riport egysége a dokumentum, a fuvar pedig az allocationökön át
**így is pontos** (6. pont). Cserébe a séma a szállítmánytervező hátralévő fázisaitól függene.
- *Future flexibility:* rossz — a P4 és a P8 minden alkalommal érintené a fogyasztási táblát.
- **A pénzügyi értékek befagyasztása a fogyasztási soron** (rejected, Adam döntése): auditálhatóbb volna
(a tavalyi riport ugyanazt adná ma is), de a fuvarszámla tipikusan az eladás **után** érkezik, tehát minden
késve érkező költség újraszámoló futtatást kívánna — és a helyesbítés kifejezetten át kell hogy üssön.
- **Bruttó alapú árrés** (rejected): a beszerzési oldal közösségi beszerzésnél áfa nélkül érkezik, belföldön áfásan,
az eladás pedig bruttó — a bruttó–bruttó különbség a szállítmányok egy részén az áfát mutatná árrésnek.
A riport **megjelenítheti** a bruttót; a számítás nettón helyes.
- **Súly szerinti fuvar-osztókulcs** (rejected): a kamion kettős korlátjának (26 raklap **és** 22 500 kg) a másik
fele, tehát védhető lett volna — de a fuvarozó helyet ad el, nem tömeget (Adam).
- **Kézi kijelölés a raktárban** (rejected): a legpontosabb volna, de nincs mögötte folyamat — a kiadáskor senki
nem rögzíti, melyik raklapról szedte le az árut, és a bevezetése a mérési folyamatot terhelné meg.
A kézi **felülírás** ettől külön kérdés, és a 8. pont `IsManual` mezője nyitva hagyja.
## Related
- Kliens-oldali ADR-ek (`FruitBankHybridApp` — a `FruitBank.Common` ennek a pluginnak a függősége):
[0003 tétel↔kamion allocation](../../../../../../FruitBankHybridApp/docs/adr/0003-shipping-item-to-shipping-mapping.md)
(a fuvar-szétosztás alapja) ·
[0004-M9F3 tört raklapszám](../../../../../../FruitBankHybridApp/docs/adr/0004-M9F3-fractional-pallets-and-remainder-grouping.md)
(az osztókulcs értéke) ·
[0001 nem mérendő tétel derivált súlya](../../../../../../FruitBankHybridApp/docs/adr/0001-non-measurable-item-derived-weight.md)
(a nem mérendő termék mért mennyisége, ami a 3. pont osztója).
- Ugyanebben a repóban: [ADR 0001-V4T7](0001-V4T7-product-master-data-storage.md) — rokon mintázat (tipizálatlan
tárolás kiváltása elsőrendű oszlopokkal), és onnan jön a `CratesPerPallet`, amiből a raklapszám származik.
- Kód-horgonyok: [`FruitBankDataController.cs`](../../Controllers/FruitBankDataController.cs) ·
`FruitBankDbContext.UpdateStockQuantityAndWeightAsync` · `PreOrderConversionService` ·
`CustomPriceCalculationService` ·
[`FmEkaerValueCalculator.cs`](../../../../../../FruitMasterErp/FruitMasterErp.Core/Services/Ekaer/FmEkaerValueCalculator.cs) ·
`FruitBank.Common/Entities/{Shipping,ShippingItem,ShippingItemToShipping}.cs`
- Tracking entry: `MGFBANKPLUG-MARGIN-T-K5V9` ([`MARGIN_TODO.md`](../MARGIN/MARGIN_TODO.md)) — a `MARGIN` topik
regisztrálva 2026-08-13-án.
+2 -1
View File
@@ -19,7 +19,8 @@ Az implementáció állapotát ADR-enként **pontosan egy** `_TODO` / `_ISSUES`
| ADR | Cím | Status | Tracking entry |
|---|---|---|---|
| [0001-V4T7](0001-V4T7-product-master-data-storage.md) | A termék mérési/logisztikai törzsadata elsőrendű, plugin-tulajdonú táblába kerül a `GenericAttribute`-ok helyett | Proposed (2026-08-11) | — (topik-kód regisztrálása szükséges, lásd az ADR `Follow-ups` szakaszát) |
| [0001-V4T7](0001-V4T7-product-master-data-storage.md) | A termék mérési/logisztikai törzsadata elsőrendű, plugin-tulajdonú táblába kerül a `GenericAttribute`-ok helyett | Proposed (2026-08-11) | `MGFBANKPLUG-PMDATA-T-H3N8` ([PMDATA_TODO](../PMDATA/PMDATA_TODO.md)) — Open |
| [0002-R7Q2](0002-R7Q2-document-cost-layers-and-margin.md) | A nyereség-kimutatás a szállítólevél szintjén számol, FIFO-val fogyasztott költség-rétegekből, és minden pénzügyi értéket lekérdezéssel állít elő | Proposed (2026-08-13) | `MGFBANKPLUG-MARGIN-T-K5V9` ([MARGIN_TODO](../MARGIN/MARGIN_TODO.md)) — Open |
## Ezt a repót érintő döntések más rétegben