Compare commits
3
Commits
46b2d3097f
...
f108e46ac4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f108e46ac4 | ||
|
|
6f23c61830 | ||
|
|
afb6b61b6a |
@@ -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"> </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"> </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>
|
||||
· ${s.licencePlate || '<em class="text-muted">nincs rendszám</em>'}
|
||||
· ${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 P3–P7 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).
|
||||
@@ -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 P3–P7 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user