Compare commits

..

5 Commits

Author SHA1 Message Date
Loretta dbc7fd31d3 Add ADR 0001 for non-measurable item weight logic
Added ADR 0001 detailing the approach for deriving and persisting pallet weights for non-measurable items using product average weight and tare. Updated README.md to document the ADR process and index the new ADR.
2026-07-14 18:35:16 +02:00
Loretta 5c20b3a04c Update GrossWeight logic, add wholesale framework plan
Changed GrossWeight in OrderItemDto to sum GrossWeight instead of NetWeight and updated its ToonDescription. Added WHOLESALE_FRAMEWORK_PLAN.md as a draft design summary for a future shared wholesale framework, outlining architecture, layering, and migration strategy.
2026-07-14 18:25:51 +02:00
Loretta 451e029869 Centralize BaseUrl logic via AcEnv; enforce fail-fast
Refactored BaseUrl handling so FruitBankConstClient delegates all access to AcEnv.BaseUrl in AyCode.Core, ensuring a single source of truth and fail-fast validation. Removed redundant _baseUrl field and logic from FruitBankConstClient. Updated startup files to hydrate BaseUrl from configuration and improved comments to clarify the new delegation and configuration conventions.
2026-07-14 07:47:07 +02:00
Loretta 25265c02f8 Add platform env-badge to app title via IAppTitleService
Introduced IAppTitleService for platform-agnostic environment badge in the app title ("[PROD]" or "[DEV] FruitBank"), with implementations for Web (document.title), MAUI (window title), and server (no-op). Registered the service per platform in DI. MainLayout now updates the title reactively on environment changes via FruitBankConstClient.IsProdChanged. Improved event handling and prevented redundant environment hydration calls. No business logic affected; changes are UI/infrastructure only.
2026-07-14 07:04:10 +02:00
Loretta 42f5c86ce4 Add environment guard & EKÁER NAV submit; test safety
- Integration tests now fail fast if run against PROD, using a new TestServerGuard and async [TestInitialize] methods.
- Test config is unified via shared appsettings.json, loaded at assembly init.
- SignalR protocol extended: server announces environment on connect; client hydrates and verifies environment, logging changes.
- EKÁER grid supports NAV submission with async UI and status updates.
- Backend and protocol updated for SendEkaerToNav support.
- Minor UI and error handling improvements.
2026-07-13 19:00:13 +02:00
31 changed files with 824 additions and 40 deletions

View File

@ -97,7 +97,10 @@
"PowerShell(Remove-Item \"H:\\\\Applications\\\\Mango\\\\Source\\\\FruitBankHybridApp\\\\FruitBank.Common\\\\Loggers\\\\FbLogItem.cs\" -Confirm:$false)",
"PowerShell(\"=== Server ===\")",
"PowerShell(\"=== Generated ===\")",
"PowerShell(\\(Get-Content \"H:\\\\Applications\\\\Aycode\\\\Source\\\\AyCode.Blazor\\\\AyCode.Blazor.Components\\\\docs\\\\MGGRID\\\\MGGRID_TODO.md\" | Measure-Object -Line\\).Lines)"
"PowerShell(\\(Get-Content \"H:\\\\Applications\\\\Aycode\\\\Source\\\\AyCode.Blazor\\\\AyCode.Blazor.Components\\\\docs\\\\MGGRID\\\\MGGRID_TODO.md\" | Measure-Object -Line\\).Lines)",
"PowerShell(\"=== FruitBank.Common ===\")",
"Bash(git check-ignore *)",
"PowerShell(dotnet build \"H:\\\\Applications\\\\Mango\\\\Source\\\\FruitBankHybridApp\\\\FruitBank.Common.Server\\\\FruitBank.Common.Server.csproj\" -v q --nologo 2>&1 | Select-String -Pattern \"error CS\" | Select-Object -First 20; \"--- CS hibak szama: ---\"; \\(dotnet build \"H:\\\\Applications\\\\Mango\\\\Source\\\\FruitBankHybridApp\\\\FruitBank.Common.Server\\\\FruitBank.Common.Server.csproj\" -v q --nologo 2>&1 | Select-String -Pattern \"error CS\" | Measure-Object\\).Count)"
]
}
}

View File

@ -74,6 +74,59 @@ public sealed class FruitBankEkaerService : IFruitBankEkaerService
history.Partner = partner;
}
public async Task<EkaerHistory> SendEkaerAsync(EkaerHistory ekaerHistory, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(ekaerHistory);
// Csak legenerált, blokkoló hiba nélküli (warninggal is) sor küldhető — a kliens is így gátol, de itt is védünk.
if (!ekaerHistory.Status.IsSubmittable() || string.IsNullOrWhiteSpace(ekaerHistory.XmlDoc))
{
ekaerHistory.Status = EkaerStatus.SendError;
ekaerHistory.ErrorText = "[Error] A bejelentés nem küldhető be: nincs legenerált, érvényes XML — előbb Generate.";
return ekaerHistory;
}
// A warning-eredetet a küldés ELŐTT rögzítjük (siker esetén ez dönt Sent vs SentWithMissingData között).
var hadWarning = ekaerHistory.Status == EkaerStatus.GeneratedWithWarning;
try
{
// A TÁROLT tradeCard-ot küldjük (amit a Generate előállított + validált), create-műveletként.
var tradeCard = NavXmlHelper.Deserialize<TradeCardType>(ekaerHistory.XmlDoc);
var operation = new TradeCardOperationType { Index = 1, Operation = OperationType.Create, TradeCard = tradeCard };
var result = await _submitService.SubmitAsync([operation], cancellationToken).ConfigureAwait(false);
// Elvileg nem fordul elő (a submittable sort a Generate már validálta), de defenzíven kezeljük.
if (!result.IsValid)
{
ekaerHistory.Status = EkaerStatus.ValidationError;
ekaerHistory.ErrorText = string.Join(Environment.NewLine, result.ValidationErrors.Select(e => $"[Error] {e.ErrorMessage}"));
return ekaerHistory;
}
// Siker: a NAV visszaadta az EKÁER-számot (tcn). Warningos generálásból SentWithMissingData (pótlásra vár), egyébként Sent.
ekaerHistory.EkaerNumber = result.Response?.TradeCardOperationsResults.FirstOrDefault()?.TradeCardInfo?.Tcn;
ekaerHistory.SentDate = DateTime.UtcNow;
ekaerHistory.Status = hadWarning ? EkaerStatus.SentWithMissingData : EkaerStatus.Sent;
// Tiszta beküldés → nincs üzenet; warningnál a meglévő szöveg (pl. hiányzó rendszám) MARAD (a pótlandó adat jelzése).
if (!hadWarning) ekaerHistory.ErrorText = null;
return ekaerHistory;
}
catch (NavReportException ex)
{
ekaerHistory.Status = EkaerStatus.SendError;
ekaerHistory.ErrorText = $"[Error] NAV ({ex.ReasonCode ?? ex.HttpStatusCode?.ToString() ?? "hiba"}): {ex.Message}";
return ekaerHistory;
}
catch (Exception ex)
{
ekaerHistory.Status = EkaerStatus.SendError;
ekaerHistory.ErrorText = $"[Error] Beküldési hiba: {ex.Message}";
return ekaerHistory;
}
}
/// <summary>Depot-kapu (T-D8R4): a PartnerDepot az EKÁER-hez kötelező — a felrakodási hely a depot címe. Hiányzó
/// vagy be nem töltött telephelynél a leképezés ELŐTT ValidationError (nincs félrevezető, partner-központi-címes XML).
/// <c>null</c> = rendben, mehet a generálás.</summary>

View File

@ -46,6 +46,15 @@ public interface IFruitBankEkaerService
/// <summary>Eldönti egy kimenő rendelés EKÁER-kötelezettségét (ugyanaz a logika, a rendelés tételeire).</summary>
EkaerObligationResult EvaluateObligation(OrderDto order);
/// <summary>
/// Beküldi egy legenerált (<see cref="EkaerStatus.Generated"/> / <see cref="EkaerStatus.GeneratedWithWarning"/>)
/// <see cref="EkaerHistory"/> TÁROLT tradeCard XML-jét a NAV-nak (create-művelet). A rekordot tölti: siker →
/// <see cref="EkaerStatus.Sent"/> (tiszta) vagy <see cref="EkaerStatus.SentWithMissingData"/> (warningos generálásból,
/// pl. hiányzó rendszám) + <c>EkaerNumber</c> (a NAV tcn) + <c>SentDate</c>; NAV-/transport-hiba →
/// <see cref="EkaerStatus.SendError"/> + <c>ErrorText</c>. NEM perzisztál — a mentés a hívó SignalR endpoint dolga.
/// </summary>
Task<EkaerHistory> SendEkaerAsync(EkaerHistory ekaerHistory, CancellationToken cancellationToken = default);
/// <summary>Beállítja a deklaráció (<see cref="EkaerHistory"/>) összegző mezőit (DB-mentes): <c>ShippingDate</c> + <c>Partner</c>.
/// A csoporton belül invariánsak; a forrás-kinyerés (bejövő: doc.ShippingDate + Partner.Name; kimenő: order.DateOfReceipt +
/// Customer.Company) a hívóé. Fallback NINCS — null/üres marad, ha hiányzik.</summary>

View File

@ -15,10 +15,14 @@ namespace FruitBank.Common.Server.Services.SignalRs;
public class DevAdminSignalRHub : AcWebSignalRHubWithSessionBase<SignalRTags, Logger<DevAdminSignalRHub>>
{
private readonly IFruitBankDataControllerServer _fruitBankDataController;
public DevAdminSignalRHub(IConfiguration configuration, IFruitBankDataControllerServer fruitBankDataController/*, SessionService sessionService*/,
ICustomOrderSignalREndpointServer customOrderSignalREndpoint, IStockSignalREndpointServer stockSignalREndpointServer, IEnumerable<IAcLogWriterBase> logWriters)
: base(configuration, new Logger<DevAdminSignalRHub>(logWriters.ToArray()))
{
_fruitBankDataController = fruitBankDataController;
EnableBinaryDiagnostics = FruitBankConstClient.SignalRSerializerDiagnosticLog;
SerializerOptions = new AcBinarySerializerOptions();
@ -30,6 +34,32 @@ public class DevAdminSignalRHub : AcWebSignalRHubWithSessionBase<SignalRTags, Lo
DynamicMethodRegistry.Register(stockSignalREndpointServer);
}
/// <summary>
/// ENV-announce (push-on-connect): minden új kapcsolat azonnal megkapja a szerver PROD/DEV besorolását
/// (ServerEnvironment tag). A kliens a tárolt értékével veti össze — eltérés (= a szerver restart közben
/// MÁSIK DB-re állt) esetén riaszt. Így a kliensnek NEM kell reconnectkor újra lekérdeznie ("csak az elején 1x").
/// Best-effort: hibája nem dönti be a kapcsolatot — az autoritatív kapu a kliens első-connect lekérdezése.
/// </summary>
public override async Task OnConnectedAsync()
{
await base.OnConnectedAsync();
try
{
var signalParams = new SignalParams
{
Status = SignalResponseStatus.Success,
DataSerializerType = AyCode.Core.Serializers.AcSerializerType.Binary
};
await Clients.Caller.OnReceiveMessage(SignalRTags.ServerEnvironment, null, signalParams, await _fruitBankDataController.IsProdEnvironment());
}
catch (Exception ex)
{
Logger.Error($"ServerEnvironment announce failed; ConnectionId: {Context.ConnectionId}", ex);
}
}
protected override void LogContextUserNameAndId()
{
return;

View File

@ -79,12 +79,12 @@ public class OrderItemDto : MgOrderItemDto<ProductDto>, IOrderItemDto
}
[NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
[ToonDescription(BusinessRule = "get => double.Round(OrderItemPallets.Sum(x => x.NetWeight), 1)")]
[ToonDescription(BusinessRule = "get => double.Round(OrderItemPallets.Sum(x => x.GrossWeight), 1)")]
public double GrossWeight
{
get
{
return double.Round(OrderItemPallets.Sum(x => x.NetWeight), 1);
return double.Round(OrderItemPallets.Sum(x => x.GrossWeight), 1);
//CommonHelper.To<double>(GenericAttributes.SingleOrDefault(x => x.Key == nameof(IMeasuringGrossWeight.GrossWeight))?.Value ?? "0");
}
set

View File

@ -7,21 +7,40 @@ public static class FruitBankConstClient
{
public static string DefaultLocale = "en-US";
// A nop szerver base URL-je. Induláskor a host tölti fel az appsettings "AyCode:Urls:BaseUrl"-ből
// (ld. Program.cs / MauiProgram.cs). Az fbHub ÉS a loggerHub is EBBŐL az egy értékből származik (+/fbHub, +/loggerHub).
// Default: null — ha nincs beállítva, az OLVASÁS exception-t dob, nem megy tovább csendben hiányzó/rossz URL-lel.
// A környezet-váltás az appsettings-ben történik:
// dev: https://localhost:59579 | prod: https://shop.fruitbank.hu | test: https://fruitbank.mangoweb.hu | android: http://10.0.2.2:59579
private static string? _baseUrl;
// DELEGÁT az AcEnv.BaseUrl-re (AyCode.Core framework, 2026-07): az érték EGY helyen, a kereten él
// (fail-fast: hidratálatlan olvasás exception). A hostok induláskor hidratálják az appsettings
// "AyCode:Urls:BaseUrl"-ből; az fbHub és a loggerHub URL-je is ebből származik (+/fbHub, +/loggerHub).
// Környezet-értékek: dev https://localhost:59579 | prod https://shop.fruitbank.hu | test https://fruitbank.mangoweb.hu | android http://10.0.2.2:59579
public static string BaseUrl
{
get => !string.IsNullOrWhiteSpace(_baseUrl)
? _baseUrl
: throw new InvalidOperationException("FruitBankConstClient.BaseUrl nincs beállítva — a host indulásakor hidratáld az appsettings 'AyCode:Urls:BaseUrl' értékéből (Program.cs / MauiProgram.cs).");
set => _baseUrl = value;
get => AcEnv.BaseUrl;
set => AcEnv.BaseUrl = value;
}
// A szerver-környezet (PROD/DEV) — az érték KIZÁRÓLAG a szerver IsProdEnvironment() válaszából jöhet
// (a szerver a DB-katalógusból sorolja be magát, fail-closed módon), URL-alakból/configból SOHA.
// Az EGYETLEN értékadási pont: FruitBankSignalRClient (kapcsolat-felépüléskor hidratál, ld. EnsureEnvironmentAsync).
// Hidratálatlan olvasás → exception (fail fast); sikertelen lekérdezés → marad hidratálatlan (fail closed).
private static bool? _isProd;
public static bool IsProd
=> _isProd ?? throw new InvalidOperationException(
"FruitBankConstClient.IsProd nincs hidratálva — a FruitBankSignalRClient a kapcsolat felépülésekor tölti fel a szerver IsProdEnvironment() válaszából (await client.EnsureEnvironmentAsync()).");
/// <summary>Az IsProd TÉNYLEGES változásakor tüzel (első hidratálás + announce-detektált env-váltás) —
/// a megjelenítők (title-badge, UI-sáv) feliratkozási pontja. Az értékadás forrása változatlanul EGY hely.</summary>
public static event Action<bool>? IsProdChanged;
public static void SetIsProd(bool isProd)
{
var changed = _isProd != isProd;
_isProd = isProd;
if (changed) IsProdChanged?.Invoke(isProd);
}
/// <summary>True, ha az IsProd már hidratált — kapu-logikához (első-connect await) és a push-összevetéshez.</summary>
public static bool IsProdHydrated => _isProd != null;
public static string DefaultHubName = "fbHub";
public static string LoggerHubName = "loggerHub";

View File

@ -35,6 +35,7 @@ public interface IFruitBankDataControllerCommon
public Task<EkaerHistory?> AddEkaerHistory(EkaerHistory ekaerHistory);
public Task<EkaerHistory?> UpdateEkaerHistory(EkaerHistory ekaerHistory);
public Task<EkaerHistory?> GenerateEkaerXmlDocument(int ekaerHistoryId);
public Task<EkaerHistory?> SendEkaerToNav(int ekaerHistoryId);
public Task<EkaerHistory?> CreateEkaerHistory(int foreignKey, bool isOutgoing);
public Task<EkaerCreateResult?> CreateMissingEkaerHistories(DateTime fromDate);
public Task<int> GetEkaerHistoryCount(EkaerHistoryFilter filter);

View File

@ -127,11 +127,15 @@ public class SignalRTags : AcSignalRTags
public const int CreateEkaerHistory = 191;
public const int CreateMissingEkaerHistories = 192;
public const int GetEkaerHistoryCount = 193;
public const int SendEkaerToNav = 194;
public const int AuthenticateUser = 195;
public const int RefreshToken = 200;
public const int IsProdEnvironment = 210;
/// <summary>Szerver→kliens ENV-announce (push-on-connect): minden új kapcsolat megkapja a szerver PROD/DEV
/// besorolását. A kliens BELSŐLEG kezeli (FruitBankSignalRClient — SetIsProd + eltérésnél riasztás), nem app-esemény.</summary>
public const int ServerEnvironment = 211;
#region SendToClient
public const int SendOrderChanged = 500;

View File

@ -27,11 +27,12 @@ namespace FruitBankHybrid.Shared.Tests
private static string GetOriginalName(string name) => name.Replace(Fixture, string.Empty);
[TestInitialize]
public void TestInit()
public async Task TestInit()
{
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
_signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankClientTests));
// Környezet-guard a szerver saját bevallása alapján (PROD / nem igazolható → exception, fail closed).
await TestServerGuard.EnsureNotProdAsync(_signalRClient);
}
#region Shipping

View File

@ -12,11 +12,12 @@ namespace FruitBankHybrid.Shared.Tests
private FruitBankSignalRClient _signalRClient = null!;
[TestInitialize]
public void TestInit()
public async Task TestInit()
{
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
_signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankEkaerTests));
// Környezet-guard a szerver saját bevallása alapján (PROD / nem igazolható → exception, fail closed).
await TestServerGuard.EnsureNotProdAsync(_signalRClient);
}
#region EkaerHistory / Create

View File

@ -76,4 +76,10 @@
<PackageReference Include="Microsoft.Extensions.ObjectPool" Version="9.0.0" />
</ItemGroup>
<!-- A KANONIKUS Shared appsettings.json linkelve — a teszt BaseUrl-hidratálása (TestEnvironment) ugyanabból
az egy forrásból, mint a hostoké; nincs külön teszt-config. -->
<ItemGroup>
<Content Include="..\FruitBankHybrid.Shared\appsettings.json" Link="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@ -18,11 +18,12 @@ namespace FruitBankHybrid.Shared.Tests
private static string GetOriginalName(string name) => name.Replace(Fixture, string.Empty);
[TestInitialize]
public void TestInit()
public async Task TestInit()
{
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
_signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankPartnerTests));
_signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankClientTests));
// Környezet-guard a szerver saját bevallása alapján (PROD / nem igazolható → exception, fail closed).
await TestServerGuard.EnsureNotProdAsync(_signalRClient);
}
#region Partner

View File

@ -186,11 +186,12 @@ public sealed class JsonExtensionTests
private FruitBankSignalRClient _signalRClient = null!;
[TestInitialize]
public void TestInit()
public async Task TestInit()
{
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
_signalRClient = TestSignalRClientFactory.Create(nameof(JsonExtensionTests));
_signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankClientTests));
// Környezet-guard a szerver saját bevallása alapján (PROD / nem igazolható → exception, fail closed).
await TestServerGuard.EnsureNotProdAsync(_signalRClient);
}
[TestMethod]

View File

@ -24,11 +24,12 @@ public sealed class OrderClientTests
private FruitBankSignalRClient _signalRClient = null!;
[TestInitialize]
public void TestInit()
public async Task TestInit()
{
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
_signalRClient = TestSignalRClientFactory.Create(nameof(OrderClientTests));
// Környezet-guard a szerver saját bevallása alapján (PROD / nem igazolható → exception, fail closed).
await TestServerGuard.EnsureNotProdAsync(_signalRClient);
}

View File

@ -0,0 +1,38 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using FruitBank.Common;
namespace FruitBankHybrid.Shared.Tests;
/// <summary>
/// Assembly-szintű környezet-hidratálás: a BaseUrl a KANONIKUS FruitBankHybrid.Shared\appsettings.json-ból jön
/// (a csproj Content-linkje másolja a teszt-outputba) — ugyanaz az egy forrás, mint a hostoknál; nincs külön
/// teszt-config és nincs hardcode. Hiányzó fájl/kulcs → azonnali exception (fail fast), pontosan mint a hostoknál.
/// A PROD-védelem NEM itt van: azt a TestServerGuard adja, a szerver saját IsProdEnvironment() bevallása alapján.
/// </summary>
internal static class TestEnvironment
{
[ModuleInitializer]
internal static void Init()
{
var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(path))
throw new InvalidOperationException(
$"appsettings.json nem található a teszt-outputban ({path}) — a csproj Content-linkje másolja a FruitBankHybrid.Shared kanonikus fájlját.");
// A kanonikus appsettings kommenteket is tartalmaz → engedékeny parse.
using var doc = JsonDocument.Parse(File.ReadAllText(path),
new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
var baseUrl = doc.RootElement.TryGetProperty("AyCode", out var ayCode)
&& ayCode.TryGetProperty("Urls", out var urls)
&& urls.TryGetProperty("BaseUrl", out var url)
? url.GetString()
: null;
FruitBankConstClient.BaseUrl = baseUrl
?? throw new InvalidOperationException(
"Hiányzik az 'AyCode:Urls:BaseUrl' az appsettings-ből — a teszt-kliens hub-URL-je ebből származik.");
}
}

View File

@ -0,0 +1,21 @@
using FruitBank.Common;
using FruitBankHybrid.Shared.Services.SignalRs;
namespace FruitBankHybrid.Shared.Tests;
/// <summary>
/// Szerver-környezet guard a szerver-routolt tesztekhez: a döntés KIZÁRÓLAG a szerver saját bevallásán
/// (IsProdEnvironment — a szerver a DB-katalógusából sorolja be magát, fail-closed) alapul; URL-alakból
/// SOHA nem következtetünk. PROD vagy nem-igazolható környezet → exception, a teszt el sem indul (fail closed).
/// </summary>
internal static class TestServerGuard
{
public static async Task EnsureNotProdAsync(FruitBankSignalRClient client)
{
// Timeout / kapcsolat-hiba / szerver-hiba → a hidratálás kivétele ide propagál → a teszt bukik.
await client.EnsureEnvironmentAsync();
if (FruitBankConstClient.IsProd)
throw new InvalidOperationException("PROD szerveren NEM tesztelünk! (a szerver IsProdEnvironment() == true)");
}
}

View File

@ -99,11 +99,12 @@ public sealed class ToonTests
private FruitBankSignalRClient _signalRClient = null!;
[TestInitialize]
public void TestInit()
public async Task TestInit()
{
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
_signalRClient = TestSignalRClientFactory.Create(nameof(ToonTests));
_signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankClientTests));
// Környezet-guard a szerver saját bevallása alapján (PROD / nem igazolható → exception, fail closed).
await TestServerGuard.EnsureNotProdAsync(_signalRClient);
}
#if DEBUG

View File

@ -73,7 +73,7 @@
<DxGridDataColumn FieldName="Created" ReadOnly="true" DisplayFormat="yyyy.MM.dd HH:mm" />
<DxGridDataColumn FieldName="Modified" ReadOnly="true" DisplayFormat="yyyy.MM.dd HH:mm" />
<DxGridDataColumn Caption="Műveletek" Width="200" AllowSort="false" AllowGroup="false">
<DxGridDataColumn Caption="Műveletek" Width="290" AllowSort="false" AllowGroup="false">
<CellDisplayTemplate>
@{
var row = (EkaerHistory)context.DataItem;
@ -89,6 +89,12 @@
Enabled="@(CanDownload(row))"
Attributes="@(new Dictionary<string, object> { ["title"] = CanDownload(row) ? "A generált XML letöltése fájlként (kézi NAV-beadáshoz)" : "Hibás bejelentés nem tölthető le — javítsd az adatokat és generáld újra." })"
Click="async () => await OnDownloadClick(row)" />
<DxButton Text="Beküldés"
SizeMode="SizeMode.Small"
RenderStyle="ButtonRenderStyle.Success"
Enabled="@(CanSend(row))"
Attributes="@(new Dictionary<string, object> { ["title"] = CanSend(row) ? "A legenerált bejelentés beküldése a NAV-nak" : "Csak legenerált, hibátlan bejelentés küldhető be." })"
Click="async () => await OnSendClick(row)" />
}
</CellDisplayTemplate>
</DxGridDataColumn>
@ -219,6 +225,12 @@
private static bool CanDownload(EkaerHistory ekaerHistory)
=> ekaerHistory.Status.IsSubmittable() && !string.IsNullOrWhiteSpace(ekaerHistory.XmlDoc);
private readonly HashSet<int> _sendingIds = [];
// Ugyanaz az előfeltétel, mint a letöltésnél (legenerálva, blokkoló hiba nélkül) + nincs épp folyamatban küldés.
private bool CanSend(EkaerHistory ekaerHistory)
=> ekaerHistory.Status.IsSubmittable() && !string.IsNullOrWhiteSpace(ekaerHistory.XmlDoc) && !_sendingIds.Contains(ekaerHistory.Id);
// A szülő-oldal (fül-számlálók) értesítése egy státuszt érintő művelet után. Üres delegáltnál no-op.
private Task NotifyDataChanged() => OnDataChanged.HasDelegate ? OnDataChanged.InvokeAsync() : Task.CompletedTask;
@ -257,6 +269,50 @@
}
}
private async Task OnSendClick(EkaerHistory ekaerHistory)
{
if (!CanSend(ekaerHistory)) return;
// A beküldés valós, kifelé irányuló művelet (a NAV-nál rögzít) — megerősítés kötelező.
var confirmed = await DialogService.ShowConfirmBoxAsync(
"EKÁER beküldés",
$"Biztosan beküldöd a NAV-nak? (#{ekaerHistory.Id} — {ekaerHistory.Partner})",
MessageBoxRenderStyle.Warning);
if (!confirmed) return;
if (!_sendingIds.Add(ekaerHistory.Id)) return;
await OnBusyChanged.InvokeAsync(true); // Loading panel BE — a NAV-hívás lassú lehet
try
{
var updated = await FruitBankSignalRClient.SendEkaerToNav(ekaerHistory.Id);
if (updated == null)
{
_logger.Error($"SendEkaerToNav null választ adott; EkaerHistory #{ekaerHistory.Id}");
return;
}
// A sor frissítése helyben — a grid ugyanazt a példányt mutatja.
ekaerHistory.Status = updated.Status;
ekaerHistory.ErrorText = updated.ErrorText;
ekaerHistory.EkaerNumber = updated.EkaerNumber;
ekaerHistory.SentDate = updated.SentDate;
ekaerHistory.Modified = updated.Modified;
}
catch (Exception ex)
{
_logger.Error($"SendEkaerToNav hiba; EkaerHistory #{ekaerHistory.Id}", ex);
}
finally
{
_sendingIds.Remove(ekaerHistory.Id);
await OnBusyChanged.InvokeAsync(false); // Loading panel KI
Grid?.Reload();
await NotifyDataChanged(); // a fül-számlálók frissítése (a sor átkerülhet az „Elküldött" tabba)
}
}
DateTime CreateFromDate { get; set; } = DateTime.Today.AddDays(-3);
private bool _creatingMissing;

View File

@ -1,6 +1,8 @@
using AyCode.Core.Consts;
using AyCode.Core.Extensions;
using AyCode.Core.Helpers;
using AyCode.Core.Loggers;
using FruitBank.Common;
using AyCode.Services.SignalRs;
using AyCode.Utils.Extensions;
using DevExpress.Blazor;
@ -9,6 +11,7 @@ using FruitBank.Common.Models;
using FruitBank.Common.Models.SignalRs;
using FruitBank.Common.SignalRs;
using FruitBankHybrid.Shared.Pages;
using FruitBankHybrid.Shared.Services;
using FruitBankHybrid.Shared.Services.Loggers;
using FruitBankHybrid.Shared.Services.SignalRs;
using Mango.Nop.Core.Loggers;
@ -23,6 +26,7 @@ public partial class MainLayout : LayoutComponentBase, IDisposable
[Inject] public required LoggedInModel LoggedInModel { get; set; }
[Inject] IToastNotificationService ToastService { get; set; }
[Inject] public required FruitBankSignalRClient FruitBankSignalRClient { get; set; }
[Inject] public required IAppTitleService AppTitleService { get; set; }
private EventCallback RefreshMainLayoutEventCallback => EventCallback.Factory.Create(this, RefreshMainLayout);
private NavMenu _navMenu = null!;
@ -47,8 +51,16 @@ public partial class MainLayout : LayoutComponentBase, IDisposable
LoggedInModel.GetRolesFunc = FruitBankSignalRClient.GetCustomerRolesByCustomerId;
FruitBankSignalRClient.OnMessageReceived += SignalRClientOnMessageReceived;
// Env-badge a címsorba (a nop-szerver Console.Title mintája): azonnal, ha az IsProd már hidratált,
// és feliratkozva a változásra (első hidratálás / announce-detektált env-váltás → riasztó title-frissítés).
// A hidratálást nem itt indítjuk — az első SignalR-hívás connect-kapuja végzi (pontosan 1 hívás).
FruitBankConstClient.IsProdChanged += OnIsProdChanged;
if (FruitBankConstClient.IsProdHydrated) OnIsProdChanged(FruitBankConstClient.IsProd);
}
private void OnIsProdChanged(bool isProd) => AppTitleService.SetEnvironmentBadgeAsync(isProd).Forget();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
@ -132,5 +144,6 @@ public partial class MainLayout : LayoutComponentBase, IDisposable
public void Dispose()
{
FruitBankSignalRClient.OnMessageReceived -= SignalRClientOnMessageReceived;
FruitBankConstClient.IsProdChanged -= OnIsProdChanged;
}
}

View File

@ -0,0 +1,11 @@
namespace FruitBankHybrid.Shared.Services;
/// <summary>
/// Platform címsor-szolgáltatás az env-badge-hez — a nop-szerver Console.Title mintájának kliens-párja
/// ("[PROD]/[DEV] FruitBank"). Host-onkénti implementáció (az IFormFactor/ISecureCredentialService mintára):
/// browser document.title / MAUI Window.Title / szerver-prerender no-op.
/// </summary>
public interface IAppTitleService
{
Task SetEnvironmentBadgeAsync(bool isProd);
}

View File

@ -45,6 +45,14 @@ namespace FruitBankHybrid.Shared.Services.SignalRs
DataSerializerType = AcSerializerType.Binary,
RawResponseData = data
};
// Szerver ENV-announce (push-on-connect) — kliens-belső kezelés, nem megy tovább app-eseményként.
if (messageTag == SignalRTags.ServerEnvironment)
{
HandleServerEnvironmentAnnounce(responseDataMessage);
return;
}
await OnMessageReceived(messageTag, responseDataMessage);
}
@ -58,7 +66,85 @@ namespace FruitBankHybrid.Shared.Services.SignalRs
public Task<List<MeasuringModel>?> GetMeasuringModels() => GetAllAsync<List<MeasuringModel>>(SignalRTags.GetMeasuringModels);
public Task<bool> IsProdEnvironment() => GetAllAsync<bool>(SignalRTags.IsProdEnvironment);
/// <summary>
/// FAIL CLOSED: a nyers SignalResponseDataMessage úton kérdez — timeout/hiba esetén EXCEPTION, nem default(false).
/// (A sima GetAllAsync&lt;bool&gt; timeoutnál default-ot adna vissza, ami "DEV"-nek hazudná a nem-elérhető szervert.)
/// </summary>
public async Task<bool> IsProdEnvironment()
{
var response = await GetAllAsync<SignalResponseDataMessage>(SignalRTags.IsProdEnvironment)
?? throw new InvalidOperationException(
"IsProdEnvironment: nincs szerver-válasz (timeout/kapcsolat-hiba) — a környezet nem igazolható (fail closed).");
return response.GetResponseData<bool>();
}
#region Server environment (IsProd) hidratálás az EGYETLEN értékadási pont a FruitBankConstClient.IsProd-hoz
private Task? _environmentHydration;
// ELSŐ felépülés: HARD GATE — a StartConnection bevárja a hidratálást, addig a kiváltó kérés nem megy ki
// (fail closed: env-tudás nélkül nem beszélünk a szerverrel). PONTOSAN EGY wire-hívás: ha a hidratálás már
// fut (pl. a teszt-guard EnsureEnvironmentAsync-je váltotta ki magát a connectet), nem indítunk másodikat —
// és nem is várjuk meg (önmagát várná: promise-ciklus lenne), az elindítója úgyis await-eli.
// Reconnect: NINCS újra-lekérdezés ("csak az elején 1x") — az env-váltást (szerver-restart MÁSIK DB-vel)
// a szerver push-on-connect announce-a jelzi (ServerEnvironment tag).
protected override async Task OnConnectionEstablished()
{
if (FruitBankConstClient.IsProdHydrated) return;
if (_environmentHydration is { IsCompleted: false }) return;
_environmentHydration = HydrateEnvironmentAsync();
await _environmentHydration;
}
/// <summary>
/// A szerver-környezet (FruitBankConstClient.IsProd) hidratálásának bevárása — teszt-guardok / UI ezt await-eli.
/// Ha az érték már megvan (gate vagy announce töltötte), azonnal kész; hibás korábbi kísérletnél újrapróbál,
/// és ha az is bukik, a kivétel a hívóra megy (fail closed).
/// </summary>
public Task EnsureEnvironmentAsync()
{
if (FruitBankConstClient.IsProdHydrated) return Task.CompletedTask;
return _environmentHydration is { IsFaulted: false, IsCanceled: false } running
? running
: _environmentHydration = HydrateEnvironmentAsync();
}
private async Task HydrateEnvironmentAsync()
{
try
{
var isProd = await IsProdEnvironment();
FruitBankConstClient.SetIsProd(isProd);
Logger.Info($"Server environment: {(isProd ? "PROD" : "DEV")}");
}
catch (Exception ex)
{
Logger.Error($"Server environment hydration FAILED — FruitBankConstClient.IsProd hidratálatlan marad (fail closed); {ex.Message}", ex);
throw;
}
}
// A szerver MINDEN kapcsolat-felépüléskor bemondja a környezetét (push-on-connect announce).
// Első alkalommal ez is hidratálhat (verseny a gate-lekérdezéssel — azonos értéket írnak, ártalmatlan);
// KÉSŐBB az összevetés a lényeg: eltérés = a szerver restart közben MÁSIK DB-re állt → hangos riasztás.
private void HandleServerEnvironmentAnnounce(SignalResponseDataMessage responseDataMessage)
{
var isProd = responseDataMessage.GetResponseData<bool>();
if (FruitBankConstClient.IsProdHydrated && FruitBankConstClient.IsProd != isProd)
Logger.Error($"SERVER ENVIRONMENT CHANGED: {(FruitBankConstClient.IsProd ? "PROD" : "DEV")} → {(isProd ? "PROD" : "DEV")} — a szerver újraindult MÁSIK adatbázissal!");
else if (!FruitBankConstClient.IsProdHydrated)
Logger.Info($"Server environment (announce): {(isProd ? "PROD" : "DEV")}");
FruitBankConstClient.SetIsProd(isProd);
}
#endregion Server environment
#region Partner
public Task<List<Partner>?> GetPartners() => GetAllAsync<List<Partner>>(SignalRTags.GetPartners);
@ -82,6 +168,7 @@ namespace FruitBankHybrid.Shared.Services.SignalRs
public Task<EkaerHistory?> AddEkaerHistory(EkaerHistory ekaerHistory) => PostDataAsync(SignalRTags.AddEkaerHistory, ekaerHistory);
public Task<EkaerHistory?> UpdateEkaerHistory(EkaerHistory ekaerHistory) => PostDataAsync(SignalRTags.UpdateEkaerHistory, ekaerHistory);
public Task<EkaerHistory?> GenerateEkaerXmlDocument(int ekaerHistoryId) => GetByIdAsync<EkaerHistory?>(SignalRTags.GenerateEkaerXmlDocument, ekaerHistoryId);
public Task<EkaerHistory?> SendEkaerToNav(int ekaerHistoryId) => GetByIdAsync<EkaerHistory?>(SignalRTags.SendEkaerToNav, ekaerHistoryId);
public Task<EkaerHistory?> CreateEkaerHistory(int foreignKey, bool isOutgoing) => GetByIdAsync<EkaerHistory?>(SignalRTags.CreateEkaerHistory, [foreignKey, isOutgoing]);
public Task<EkaerCreateResult?> CreateMissingEkaerHistories(DateTime fromDate) => GetByIdAsync<EkaerCreateResult?>(SignalRTags.CreateMissingEkaerHistories, fromDate);
public Task<int> GetEkaerHistoryCount(EkaerHistoryFilter filter) => GetByIdAsync<int>(SignalRTags.GetEkaerHistoryCount, filter);

View File

@ -18,8 +18,8 @@ using Microsoft.Extensions.Options;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// Hub-URL-ek EGY forrásból: az fbHub és a loggerHub is az AyCode:Urls:BaseUrl-ből származik.
// A loggerHub writer a FruitBankConstClient.BaseUrl-t olvassa, ezért induláskor onnan hidratáljuk.
// Hub-URL-ek EGY forrásból: az fbHub és a loggerHub is a BaseUrl-ből származik
// (a FruitBankConstClient.BaseUrl az AcEnv.BaseUrl keret-állapotra delegál — fail-fast).
FruitBankConstClient.BaseUrl = builder.Configuration["AyCode:Urls:BaseUrl"]
?? throw new InvalidOperationException("Hiányzik az 'AyCode:Urls:BaseUrl' az appsettings-ből — a hub-ok URL-je ebből származik.");
@ -28,6 +28,7 @@ builder.Services.AddDevExpressBlazor(configure => configure.SizeMode = DevExpres
// Add device-specific services used by the FruitBankHybrid.Shared project
builder.Services.AddSingleton<IFormFactor, FormFactor>();
builder.Services.AddSingleton<ISecureCredentialService, WebSecureCredentialService>();
builder.Services.AddSingleton<IAppTitleService, WebAppTitleService>();
#if DEBUG
builder.Services.AddSingleton<IAcLogWriterClientBase, BrowserConsoleLogWriter>();

View File

@ -0,0 +1,11 @@
using FruitBankHybrid.Shared.Services;
using Microsoft.JSInterop;
namespace FruitBankHybrid.Web.Client.Services;
/// <summary>Böngésző-tab title env-badge: "[PROD]/[DEV] FruitBank" (a nop-szerver Console.Title mintája).</summary>
public class WebAppTitleService(IJSRuntime js) : IAppTitleService
{
public async Task SetEnvironmentBadgeAsync(bool isProd)
=> await js.InvokeVoidAsync("eval", $"document.title = '{(isProd ? "[PROD]" : "[DEV]")} FruitBank'");
}

View File

@ -17,7 +17,8 @@ using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
// Hub-URL-ek EGY forrásból: az fbHub és a loggerHub is az AyCode:Urls:BaseUrl-ből származik.
// Hub-URL-ek EGY forrásból: az fbHub és a loggerHub is a BaseUrl-ből származik
// (a FruitBankConstClient.BaseUrl az AcEnv.BaseUrl keret-állapotra delegál — fail-fast).
FruitBankConstClient.BaseUrl = builder.Configuration["AyCode:Urls:BaseUrl"]
?? throw new InvalidOperationException("Hiányzik az 'AyCode:Urls:BaseUrl' az appsettings-ből — a hub-ok URL-je ebből származik.");
@ -27,6 +28,7 @@ builder.Services.AddMvc();
builder.Services.AddSingleton<IFormFactor, FormFactor>();
builder.Services.AddSingleton<ISecureCredentialService, ServerSecureCredentialService>();
builder.Services.AddSingleton<IAppTitleService, ServerAppTitleService>();
builder.Services.AddSingleton<IAcLogWriterBase, ConsoleLogWriter>();

View File

@ -0,0 +1,9 @@
using FruitBankHybrid.Shared.Services;
namespace FruitBankHybrid.Web.Services;
/// <summary>No-op: a szerver-host (prerender) nem birtokol címsort — az env-badge a WASM/MAUI kliensé.</summary>
public class ServerAppTitleService : IAppTitleService
{
public Task SetEnvironmentBadgeAsync(bool isProd) => Task.CompletedTask;
}

View File

@ -23,7 +23,6 @@
}
]
}
},
"AcHubConnection": {
// Az Url innen KIVÉVE: az fbHub és a loggerHub is az AyCode:Urls:BaseUrl-ből származik (kódban: +/fbHub, +/loggerHub).

View File

@ -47,7 +47,8 @@ namespace FruitBankHybrid
}
}
// Hub-URL-ek EGY forrásból: az fbHub és a loggerHub is az AyCode:Urls:BaseUrl-ből származik.
// Hub-URL-ek EGY forrásból: az fbHub és a loggerHub is a BaseUrl-ből származik
// (a FruitBankConstClient.BaseUrl az AcEnv.BaseUrl keret-állapotra delegál — fail-fast).
FruitBankConstClient.BaseUrl = builder.Configuration["AyCode:Urls:BaseUrl"]
?? throw new InvalidOperationException("Hiányzik az 'AyCode:Urls:BaseUrl' az appsettings-ből — a hub-ok URL-je ebből származik.");
@ -75,6 +76,7 @@ namespace FruitBankHybrid
// Add device-specific services used by the FruitBankHybrid.Shared project
builder.Services.AddSingleton<IFormFactor, FormFactor>();
builder.Services.AddSingleton<ISecureCredentialService, MauiSecureCredentialService>();
builder.Services.AddSingleton<IAppTitleService, MauiAppTitleService>();
builder.Services.AddSingleton<LoggedInModel>(sp => new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>()));

View File

@ -0,0 +1,21 @@
using FruitBankHybrid.Shared.Services;
namespace FruitBankHybrid.Services;
/// <summary>
/// MAUI ablak-cím env-badge: "[PROD]/[DEV] FruitBank" — Windows desktopon látszik;
/// Android/iOS-en nincs futásidőben írható ablakcím (ott hatástalan, majd egy UI-badge fedheti).
/// </summary>
public class MauiAppTitleService : IAppTitleService
{
public Task SetEnvironmentBadgeAsync(bool isProd)
{
MainThread.BeginInvokeOnMainThread(() =>
{
var window = Application.Current?.Windows.FirstOrDefault();
if (window != null) window.Title = $"{(isProd ? "[PROD]" : "[DEV]")} FruitBank";
});
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,324 @@
# Wholesale Framework — tervezési összefoglaló (handoff-mag)
> **Status:** Proposed (Draft) — 2026-07-14
> **Jelleg:** Ez NEM ADR és NEM végleges doksi. Provizórikus gondolkodási összefoglaló, ideiglenesen a
> FruitBankHybridApp `docs/` mappájában. A tárgyalt Wholesale framework-repo **még nem létezik**, a neve
> **változhat**, és **semmilyen döntés nem végleges**. Amint a repo létrejön, ez a fájl a forrása a rendes
> struktúrának (ADR-ek + repo-doksik) — lásd a záró "Mi lesz ebből" szakaszt.
> **Előzmény:** két korábbi session terv-összegzése (app-oldal + nop-plugin-oldal) + ez a session konszolidációja.
---
## 1. Cél és kiváltó ok
A FruitBank (zöldség-gyümölcs nagyker, nopCommerce-plugin + Blazor Hybrid app) mellé indul a **VeggieVoyage**
(veggievoyage.hu), egy közel azonos nagyker-rendszer apró eltérésekkel. Emellett **több további zöldséges nagyker
érdeklődő** is van a rendszerre. Cél: egy **közös nagyker-framework réteg**, amelyből minden nagyker-termék
származik, hogy egy új terméknél a lehető legkevesebbet kelljen újraírni.
Jelenleg (2026-07-14) csak a FruitBank-oldal létezik; sem a Wholesale-repo/könyvtár, sem a VeggieVoyage-projekt
nincs még létrehozva.
---
## 2. Architektúra-döntés: base-class minta (maximalista pull-up)
**Öröklés-alapú, NEM típus-átköltöztetés.** A közös rétegbe **szinte minden osztály felkerül** — abstract vagy
virtual formában (base class-ok, virtual method-ok/property-k). A konkrét (gyakran szinte üres) záró osztályok a
termék-repóban élnek és származnak a base-ből.
- **Amit lehet, a közös rétegben implementálunk** (virtual default üzleti logika); ami termék-specifikus, az a
származtatottban override-ol.
- **Az eltérés a fejlesztés közben derül ki, nem előre.** Default: minden felmegy közös virtual defaultként. Amikor
a VeggieVoyage (vagy egy későbbi termék) fejlesztése **bizonyítja**, hogy valami mégsem közös, akkor az a member
lemegy ("demote") a záró rétegbe. Amíg csak egy termék használ egy membert, a lehúzása senkit nem tör.
- Ez a **meglévő házi minta** kiterjesztése: `MgStockTakingItem<TStockTaking, TProduct> : MgEntityBase`,
`PartnerBase → CargoPartner`, `MeasuringItemPalletBase → 3 pallet`, `AcConst` abstract-constants minta.
### Miért ez nyert (a mozgatásos alternatívával szemben)
| Aggály | Feloldás |
|---|---|
| Tábla-nevek (`fbShipping`…) | `[Table(Name=…)]` a **származtatott** osztályon, termék-konstansból — nincs migráció, nincs runtime-konfig |
| AcBinary wire-compat (`SignalDataType` = AssemblyQualifiedName) | Konkrét típusok **helyben maradnak** a termék-assembly-ben → AQN változatlan, nincs lockstep-kényszer |
| `AcBinaryForcedInit` (AssemblyLoadContext-quirk) | Generált szerializálók a konkrét típusokra → meglévő ForceRegister érintetlen |
| LinqToDB `NameCompatibility` | Termék-pluginban marad, konkrét típusokra |
**Következmény:** a FruitBank-oldal minden pull-up lépésnél **fordítás-azonos** marad; a VeggieVoyage viszont
"majdnem készen" indul, mert a scaffold záró osztályai szinte üresek.
### Új DB + új plugin a VeggieVoyage-nek
- A VeggieVoyage **teljesen új adatbázist** kap → a `[Table(Name=…)]` (identitás) így is a záró rétegen marad; a
base hordozza az összes `[Column]`-t, a tábla-NÉV termék-konstans. A `vv`/`fb` prefix szabad termék-döntés, nem
framework-ügy.
- A VeggieVoyage **új nopCommerce-plugin** is → nem csak az üzleti logikát, hanem a **plugin-infrastruktúrát** is
base-formában ki kell szervezni: install/uninstall, locale-resource telepítés, admin-menü regisztráció,
settings-defaultok, schedule task-ok, `MgwPluginStartupBase` virtual DI-regisztrációkkal. A Veggie-pluginban
ideálisan csak `plugin.json` (SystemName), a záró osztályok és a konstansok maradnak.
- **Zöldmezős schema-generálás:** a friss install-nak a teljes sémát egy menetben kell felhúznia üres DB-n (a
FruitBank táblái évek alatt, lépésenként jöttek létre). Ellenőrizendő, hogy a meglévő install/tábla-létrehozó út
(MgDbContext-vonal) teljes és determinisztikus-e nulláról — GenericAttribute-kulcsok és seed-adatok is.
---
## 3. Rétegzés / célstruktúra
Rétegtérkép a legáltalánosabbtól (legmélyebb) a legspecifikusabbig:
```
L0 AyCode.Core (Ac*) + AyCode.Blazor — tech-framework, domain-mentes, bármely projekt
SignalR, AcBinary + SourceGenerator, logolás, NAV/EKÁER-protokoll, MgGrid/komponens-base-ek
L2 Mango.Nop Libraries (Mg*) — nop-generikus, nop-VERZIÓFÜGGŐ (4.70 mappa alatt)
L2.5 Mango.Nop.Wholesale.Core / .Services — nagyker-domain, nop-VERZIÓFÜGGŐ, prefix: Mgw
(Libraries alatt, 4.70) entitás-bázisok, DbTable/DbContext-bázisok, endpoint-bázisok, EKÁER-riport
L2.5 Wholesale.Common / .Common.Server — nagyker-domain, nop-verzió-FÜGGETLEN (ÚJ repo)
domain-modell, interfészek, enumok, DTO-bázisok, SignalR-tag core-tartomány, mérés-helperek,
service-bázisok (a Mango.Nop DLL-jei UTÁN build-el — pont mint ma a Common-ok)
L2.5 Wholesale.Shared (Blazor, ÚJ repo) — grid-/komponens-base-ek, SignalR-kliens base
L3 Termékek: FruitBank / VeggieVoyage / … — vékony záró osztályok + identitás + termék-delta
```
### Kétotthonos base-réteg — a besorolás kritériuma
A "hova kerüljön" kritérium **NEM az, hogy használ-e Mango.Nop-ot** (a Common-ok ma is jogosan használják),
hanem hogy **nop-verziófüggő-e**:
- **nop-verziófüggő** (Mg*/linq2db/nopCommerce-entitásokra épül, a nop belső API-ját tükrözi) → `Mango.Nop.Wholesale`
(Libraries, verziómappa alatt).
- **nop-verzió-független** nagyker-domain → `Wholesale.Common` / `.Common.Server` (új repo).
> Fontos: a mai `FruitBank.Common.Server` "nopCommerce integration"-t is tartalmaz. Pull-upnál a nop-ot érintő
> részek a `Mango.Nop.Wholesale.Services`-be mennek, nem a `Wholesale.Common.Server`-be. A kritérium szigorúan
> "nop-verziófüggő?", nem projektnév.
### Build-lánc (interleaved — ma is így épül, csak beékelődik két réteg)
```
AyCode → Mango.Nop Libraries → Mango.Nop.Wholesale.Core (entitás-bázisok)
→ Wholesale.Common / .Common.Server (domain- és service-bázisok, Mgw.Core-ra épülve)
→ Mango.Nop.Wholesale.Services (DbTable/DbContext/endpoint-bázisok, Wholesale.Common-ra épülve)
→ termékek (HybridApp + plugin)
```
Ezért a `Mango.Nop.Wholesale` **Core/Services szétvágása kötelező**, nem ízlés: a Core a Wholesale.Common ALATT,
a Services FELETTE — különben körkörös függőség.
### Per-app bin-profil
A referencia-minta már ma per-app build-profilos: az AyCode és Mango.Nop.Core DLL-jei
`bin\FruitBank\$(Configuration)\net9.0`-ból jönnek (HintPath). Lesz `bin\VeggieVoyage\…` is. TFM-ek: Common=net9.0
(WASM-kliens is fogyasztja, szerver-only függőség nélkül), Shared=net10.0. A `FruitBankHybrid.Shared.Common`
projekt ma üres váz — felhasználható.
---
## 4. Placement-kánon (a legfontosabb szabály)
**A kód a legmélyebb rétegbe kerül, amelynek a domain-hatóköre még jogosan magába foglalja a fogalmat.**
Két teszt együtt dönt:
1. **Kimenő-függőség teszt (szükséges, objektív):** egy típus akkor *mehet* mélyebbre, ha a függőségei engedik.
AyCode-ba csak az kerülhet, ami *nulla* nagyker/nop fogalomra hivatkozik (a `using`-ok és hivatkozott típusok
döntik el, nem érzés). Ez adja meg, milyen mélyre **mehetne** a kód.
2. **Hatókör-kohézió teszt (elégséges, logikai):** hova **való** a fogalom. Ez megállíthatja a kódot, mielőtt a
függőségei alapján túl mélyre süllyedne.
**Kanonikus példa — tálca/tare-aritmetika:** tiszta `decimal`-matematika, nulla domain-függőséggel *lefordulna*
AyCode.Core-ban. Mégis a **Wholesale**-be való, mert a fogalom hatóköre "nagyker-mérés". Core-ba nem azért nem
kerül, mert nem fordulna, hanem mert a Core hatóköre (*minden* projekt) tágabb, mint a fogalom valódi közönsége.
### Két hibamód
- **Túl mélyre** (nagyker-fogalom Core-ban): a univerzális réteg beszennyeződik, a nagyker-fejlesztő nem is ott
keresi. Ez a *csábító* hiba ("ez matek, mehet Core-ba"). **Fordul, csak koszol** → csendes hiba, ezt kell fogni.
- **Túl sekélyen** (a termékben): redundancia — a következő zöldséges projekt újraírja. Ezt a framework szünteti meg.
Iránytű: a redundancia-kerülés **lefelé** (általánosabb felé) tol; a hatókör-kohézió a **fék**. A hiba-irány
aszimmetrikus: túl mélyre tett domain-kód *nem fordul* (a rendszer önvédő); túl magasra tett specifikus kód
*fordul, csak koszol*. Ezért a default "a legmélyebb, ami fordul", és a **feljebb-tétel** az, ami indoklást igényel.
### Redundancia-ellensúly (hogy a DRY ne forduljon visszájára)
"Ne írd le kétszer ugyanazt a **fogalmat**" — NEM "vonj össze mindent, ami hasonlít". Ha két dolog csak *véletlenül*
hasonlít (nem ugyanaz a fogalom) és közös base-be húzod, hamis csatolást hozol létre — a legrosszabb fajta
redundancia-"javítás". A **rule-of-two/N** a védőkorlát: legalább két valódi fogyasztó bizonyítsa, hogy a közösség
valódi és nem esetleges.
### Identitás soha nem megy fel (checklist a záró réteghez)
- `[Table(Name=…)]` tábla-nevek (termék-konstansból)
- `[AcBinarySerializable]` konkrét típusok
- SignalRTag termék-tartománybeli értékei
- plugin SystemName, route-URL-ek, locale-fájlok, branding
- appsettings **értékek** (nem a séma)
### Hol él a kánon (doc-layering)
A placement-kánon kánonja a legmélyebb rétegben (AyCode.Core) él; minden alsóbb réteg copilot-instructions.md-je
erre **mutat** + a saját befogadási kritériumát mondja ki (AyCode = "domain-mentes"; Wholesale = "nagyker-domain,
nop-verzió-független"; Mango.Nop = "nop-verziófüggő"). A doksi vigyen 3-4 **levezetett** határeset-példát (a
tare-képlet a kanonikus) — az absztrakt szabály nem elég horgony, a *gondolkodásmódot* a példák tanítják.
---
## 5. Zöldség-kalibráció + jövőbeli továbbbontás (elhalasztott)
Az érdeklődők **egyelőre mind zöldséges nagykerek**, közel azonos igénnyel. Ezért a Wholesale-réteg **mai valódi
hatóköre "zöldséges nagyker"**, nem "általános nagyker" — a "zöldséges-specifikus vs. általános-nagyker" hasítás
**jövőbeli, szándékosan még meg nem tett rétegvágás**.
Következmények:
- **A zöldséges-specifikusság ma NEM tolja fel a kódot a termékbe.** Egy kimondottan zöldséges logika (érettség,
osztályozás, zöldség-mérés) jogosan a framework-rétegé — bármely másik zöldséges nagyker újra használná. Óvakodni
attól, hogy "ez túl zöldség-specifikus, hagyjuk a termékben" — ez **kiéheztetné a framework-öt**.
- **NE annotáld előre** a "zöldséges vs. általános" varratot — nincs második nem-zöldséges domain, ami bizonyítaná,
tehát bármilyen jelölés spekuláció (ugyanaz a korai-triázs-csapda). A jövőbeli *nem*-zöldséges nagyker fogja
felfedni a varratot, ugyanúgy, ahogy a Veggie-scaffold felfedi a termék-hardcode-ot.
- Ha egyszer jön nem-zöldséges nagyker: ugyanaz a mechanikus pull-up egy szinttel lejjebb (pl. `Trade`/`Distribution`
réteg), a Wholesale maga válik vékony leszármazottá. Mivel minden abstract/virtual, ez is fordítás-azonos lesz.
- A rétegnév lehet jövő-álló ("Wholesale", prefix `Mgw`), de a doksinak ki kell mondania a mai zöldség-kalibrációt,
hogy egy jövőbeli session ne nézze design-hibának és ne kezdjen kéretlenül általánosítani.
---
## 6. nopCommerce-verzió probléma + karantén-stratégia
**Kiváltó félelem:** a nopCommerce design sok hibája miatt gyakran nop-kódot kell másolni/átírni → nagyon nehéz a
FruitBank upgrade-je. A VeggieVoyage viszont már **új nop-verzióval** készülne. Kockázat: ha a catalog-táblák
változtak, az gond lehet.
**A rétegzés maga a válasz — csak explicit szabályként kell kimondani:**
1. **A verziófüggés karanténban van.** A `Mango.Nop Libraries` + `Mango.Nop.Wholesale` definíció szerint
nop-verziófüggő (ezért él verziómappa alatt). Új nop-verziónál **ez a két réteg forkol** (`4.90/Libraries`,
`4.90/…Wholesale`), és a per-app bin-profil ezt kiszolgálja: a FruitBank a 4.80-as fork ellen build-el, a
VeggieVoyage a 4.90-es ellen — **ugyanabból a Wholesale-forrásból**. Feltétel: a Mg*/Mgw típusok **publikus
felülete** forrás-kompatibilis maradjon a forkok között. A Mango.Nop réteg így **anti-korrupciós réteg**: a nop
churn a fork belsejében marad, a Wholesale nem látja.
2. **Másolt/átírt nop-kód** (a tényleges upgrade-fájdalom): jól megjelölt helyre (pl. `NopOverrides/` a verziófüggő
rétegben), mindegyik mellé kötelező jegyzet: *miért kellett, melyik nop-fájlból, mit kell újranézni port-nál*.
Így a 4.90-port checklist lesz, nem régészet.
3. **Catalog-tábla drift:** (a) minden catalog-hozzáférés a verziófüggő rétegben, a Wholesale csak interfészt/DTO-t
lát; (b) a **GenericAttribute-minta** (IsMeasurable, Tare, AverageWeight, IncomingQuantity, NetWeight mind GA-ban,
nem catalog-oszlopban) verzió-rezisztens — a GA a nop legstabilabb mechanizmusa. A kitettség arra szűkül, amit
tényleg a Product/Order/Customer **natív oszlopaiból** olvastok.
4. **A két nagy műveletet ne egyszerre.** Előbb pull-up a **mostani** nop-verzión (FruitBank fordítás-azonos, a
Veggie-scaffold ugyanazon a verzión bizonyítja a framework-öt), **utána** a verziófüggő réteg portja az új
verzióra. Egyszerre futtatva minden hiba kétesélyes lenne ("framework-bug vagy port-bug?").
5. **Reframe:** a VeggieVoyage **kifizeti a FruitBank upgrade-adósságát**. Ha a Veggie kikényszeríti a
Mango.Nop(+Wholesale) új-verziós portját, a FruitBank későbbi upgrade-je már csak "bin-profil átállítás a kész
forkra + termékrétegbeli maradék".
**Konkrét vizsgálat, amivel a catalog-félelem végessé tehető** (még nem futott le):
- (a) **Kitettség-térkép:** végigmenni a plugin + Libraries kódon, mely nop-entitásokat/service-eket/natív
oszlopokat használtok közvetlenül.
- (b) **Séma-diff** a cél nop-verzióval, a ténylegesen használt táblákra (a nop upgrade-migrációiból).
→ Cél nop-verzió megadása után elvégezhető.
---
## 7. Eltérés-taxonómia: settings vs. override (rule-of-N)
Több terméknél a "rule-of-two" **rule-of-N** lesz. Ökölszabály:
- **parametrikus eltérés → settings/config** (EkaerSettings-minta, DI mögé)
- **strukturális eltérés → virtual override**
- **identitás → abstract const a záró rétegen**
Ha ugyanazt a metódust minden termék máshogy override-olja, az annak a jele, hogy **paraméternek** kellett volna
lennie. Minél több megy settings-be, annál kevesebb kódot kell egy új terméknél megírni. Nem kell előre kitalált
settings-taxonómia — **demote-to-settings on evidence**, ugyanaz a workflow, mint a kódnál: minden fent marad közös
defaultként, és az első valódi termék-eltérésnél dől el, override vagy setting lesz.
**Demote-ablak szűkül N-nel:** amíg egy termék használ egy membert, a lehúzása fájdalommentes; a 2. fogyasztónál már
közös és fent marad. Ezért a Veggie-fázisban agresszíven demote-olni.
**Termék-scaffold = template.** A Veggie "új termék felhúzása" maga is termék: minden scaffold-lépést checklistként
dokumentálni a Wholesale-repo doksijába — a 3. terméknél már ez A termék-létrehozási folyamat. (Nem kell most
dotnet-template; a jól dokumentált Veggie-scaffold az első draft.)
**Framework-evolúció N fogyasztóval:** minden wholesale-változás N terméket érint. Per-app bin-profil jó izoláció
(mindenki a saját build-jét pinneli), de wire-formátumot (AcBinary) és base-séma `[Column]`-okat érintő változásnál
`CONSUMERS.md` szerinti végigfuttatás kell (P1).
**SignalRTag-tartomány kiosztás** (hogy két termék soha ne ütközzön közös infrastruktúrán): közös core-tartomány +
termék-tartományok, pl. FruitBank 09999, VeggieVoyage 1000019999, 3. termék 20000+…
---
## 8. Pilotban igazolandó mechanizmusok (a maximalista minta ezekre tesz fel mindent)
Az első pull-up ELŐTT explicit tesztelendő:
1. **`[Association]` keresztreferenciák öröklésnél** — interfész-kulcsú társítás
(`OtherKey = nameof(IShipping.Id)`) VAGY a navigációk a leszármazottban maradnak (base = skalárok + FK int-ek),
entitásonként döntve.
2. **AcBinary öröklött property-kkel** — a `MeasuringItemPalletBase` minta szerint; az első költözőnél explicit teszt.
3. **`[SignalR]` attribútum öröklött kontroller-metóduson** — az AyCode dispatcher végpont-felderítése lássa a
base-metódusokat.
4. **Razor `@typeparam` a DevExpress template-ekkel** — hol megy, hol marad vékony app-razor.
---
## 9. Migrációs fázisterv ("pull-up", lépésenként, FruitBank végig fordítás-azonos)
0. Skeleton repo + per-app bin-profil bekötés + mechanikus bázis-kiemelés (interfészek, enumok, konstans-bázisok) —
compile+wire-identical.
1. **Pilot:** a legkisebb/legtisztább gráf végigvitele (jelölt: Cargo — legkisebb; vagy EKÁER-lánc — legfrissebb).
Itt bizonyítjuk a 8. szakasz mechanizmusait.
2. Data-réteg bázisok (DbContext/DbTable).
3. Service-ek domain-enként (mérés → preorder → cargo → invoicing), FruitBank-osztályok vékony leszármazottá.
4. VeggieVoyage-plugin scaffold záró-osztályokkal — itt bukik ki a maradék hardcode.
5. Blazor Shared (grid-/komponens-base-ek).
6. Verziófüggő réteg portja az új nop-verzióra (a pull-up UTÁN, külön lépés).
---
## 10. Meta-protokoll teendők (amikor a repo létrejön)
- Új repo: `.github/copilot-instructions.md` + `@repo` blokk, **repo-prefix** (self-service a `REPO_PREFIXES.md`
szerint; korábbi javaslatok: nop-oldali libre `MGNOPWHOLE`, a nop-független Wholesale repóra pl. `MGWHOLE`
eldöntendő), layer-szám.
- **Class-prefix:** `Mgw` (Mango Wholesale) — collision-check lefutott, tiszta (a `Ws`, `Wh/Whs`, `Nk` elvetve).
A doksi tisztázza: a prefix jövő-álló, de a mai tartalom zöldség-kalibrált.
- **Doc-layering:** a defining layerrel a doksik is költöznek — a FruitBank-plugin `PREORDER/` doksija + a
`PREO`/`EKAER`/`ORDD` topic-kódok a wholesale-registry-be; a termék-pluginban pointer + delta marad.
- **`CONSUMERS.md`** a Wholesale-repóban (FruitBank, VeggieVoyage, …).
- **`LLMP-DEC` bejegyzés** az új repo létéről (workspace-szignifikáns).
- **ADR-ek** a végleges struktúrában (lásd lent).
---
## 11. Nyitott kérdések
- Wholesale-repo **neve** és pontos szeparációja (egy repo Common + Common.Server + Shared, vagy több?).
- **Cél nop-verzió** a VeggieVoyage-hez (ez indítja a 6/(b) séma-diffet).
- Repo-**prefix**(ek) véglegesítése.
- Generic-paraméter-robbanás mély gráfoknál (`MgwShippingDocumentBase<TShipping, TPartner, TShippingItem, >`) —
a házi minta elfogadta, de ez az ára; entitásonként figyelni.
- `Mango.Nop` vs `Wholesale` határ entitásonként: ami bármely nop-projektnek jó → Mango.Nop; ami nagyker-domain →
Wholesale.
---
## 12. Mi lesz ebből (amint van repo)
Ez a fájl a **forrás**; a rendes struktúrába szétszedve:
- **ADR-ek** (`<repo>/docs/adr/NNNN-*.md`, Nygard-stílus):
- `base-class-pull-up-vs-type-move` (2. szakasz)
- `wholesale-layer-split-and-nop-version-quarantine` (3. + 6. szakasz)
- `placement-canon` (4. szakasz) — vagy CONVENTIONS.md-be, a repo szokása szerint
- **Repo-doksik:** ARCHITECTURE (rétegtérkép + build-lánc), CONVENTIONS (placement-kánon + tare-példa +
zöldség-kalibráció), CONSUMERS.md, termék-scaffold checklist.
- **LLMP-DEC** sor: az új repo léte + a protokoll-relevanciák.
- A FruitBank-oldali doc-layering pointer-ek frissítése.
Ez a `WHOLESALE_FRAMEWORK_PLAN.md` a szétszedés után **archiválható vagy törölhető** (a tartalma a végleges
helyekre kerül).

File diff suppressed because one or more lines are too long

11
docs/adr/README.md Normal file
View File

@ -0,0 +1,11 @@
# Architecture Decision Records (ADR)
Architektúra-szintű döntések durable rekordjai (Nygard-stílus): egy fájl = egy döntés, `NNNN-<slug>.md` néven. Minden ADR: kontextus → döntés → következmények → alternatívák. A `Status` a Nygard 4-értékű szótár: `Proposed` / `Accepted` / `Superseded by ADR-XXXX` / `Rejected` (ez KÜLÖN a `_ISSUES.md`/`_TODO.md` 3-értékű `Open`/`InProgress`/`Closed` szótárától).
> Formátum + folyamat: `adr-author` skill (AyCode.Core `.github/skills/adr-author/`), sablon: `references/ADR_TEMPLATE.md`.
## Index
| ADR | Cím | Status |
|---|---|---|
| [0001](0001-non-measurable-item-derived-weight.md) | Nem mérendő tétel súlya — derivált pallet-mérés a termék átlagsúlyából és tárájából | Proposed (2026-07-14) |