Compare commits
No commits in common. "dbc7fd31d394db57083aac7a7878a851b15f70e8" and "7fc6c8a8ef44abfc4da3d1eb2d40ad71e260cb51" have entirely different histories.
dbc7fd31d3
...
7fc6c8a8ef
|
|
@ -97,10 +97,7 @@
|
||||||
"PowerShell(Remove-Item \"H:\\\\Applications\\\\Mango\\\\Source\\\\FruitBankHybridApp\\\\FruitBank.Common\\\\Loggers\\\\FbLogItem.cs\" -Confirm:$false)",
|
"PowerShell(Remove-Item \"H:\\\\Applications\\\\Mango\\\\Source\\\\FruitBankHybridApp\\\\FruitBank.Common\\\\Loggers\\\\FbLogItem.cs\" -Confirm:$false)",
|
||||||
"PowerShell(\"=== Server ===\")",
|
"PowerShell(\"=== Server ===\")",
|
||||||
"PowerShell(\"=== Generated ===\")",
|
"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)"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,59 +74,6 @@ public sealed class FruitBankEkaerService : IFruitBankEkaerService
|
||||||
history.Partner = partner;
|
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ó
|
/// <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).
|
/// 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>
|
/// <c>null</c> = rendben, mehet a generálás.</summary>
|
||||||
|
|
|
||||||
|
|
@ -46,15 +46,6 @@ 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>
|
/// <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);
|
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>.
|
/// <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 +
|
/// 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>
|
/// Customer.Company) a hívóé. Fallback NINCS — null/üres marad, ha hiányzik.</summary>
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,10 @@ namespace FruitBank.Common.Server.Services.SignalRs;
|
||||||
|
|
||||||
public class DevAdminSignalRHub : AcWebSignalRHubWithSessionBase<SignalRTags, Logger<DevAdminSignalRHub>>
|
public class DevAdminSignalRHub : AcWebSignalRHubWithSessionBase<SignalRTags, Logger<DevAdminSignalRHub>>
|
||||||
{
|
{
|
||||||
private readonly IFruitBankDataControllerServer _fruitBankDataController;
|
public DevAdminSignalRHub(IConfiguration configuration, IFruitBankDataControllerServer fruitBankDataController/*, SessionService sessionService*/,
|
||||||
|
|
||||||
public DevAdminSignalRHub(IConfiguration configuration, IFruitBankDataControllerServer fruitBankDataController/*, SessionService sessionService*/,
|
|
||||||
ICustomOrderSignalREndpointServer customOrderSignalREndpoint, IStockSignalREndpointServer stockSignalREndpointServer, IEnumerable<IAcLogWriterBase> logWriters)
|
ICustomOrderSignalREndpointServer customOrderSignalREndpoint, IStockSignalREndpointServer stockSignalREndpointServer, IEnumerable<IAcLogWriterBase> logWriters)
|
||||||
: base(configuration, new Logger<DevAdminSignalRHub>(logWriters.ToArray()))
|
: base(configuration, new Logger<DevAdminSignalRHub>(logWriters.ToArray()))
|
||||||
{
|
{
|
||||||
_fruitBankDataController = fruitBankDataController;
|
|
||||||
|
|
||||||
EnableBinaryDiagnostics = FruitBankConstClient.SignalRSerializerDiagnosticLog;
|
EnableBinaryDiagnostics = FruitBankConstClient.SignalRSerializerDiagnosticLog;
|
||||||
SerializerOptions = new AcBinarySerializerOptions();
|
SerializerOptions = new AcBinarySerializerOptions();
|
||||||
|
|
||||||
|
|
@ -34,32 +30,6 @@ public class DevAdminSignalRHub : AcWebSignalRHubWithSessionBase<SignalRTags, Lo
|
||||||
DynamicMethodRegistry.Register(stockSignalREndpointServer);
|
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()
|
protected override void LogContextUserNameAndId()
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -79,12 +79,12 @@ public class OrderItemDto : MgOrderItemDto<ProductDto>, IOrderItemDto
|
||||||
}
|
}
|
||||||
|
|
||||||
[NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
[NotColumn, NotMapped, JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
|
||||||
[ToonDescription(BusinessRule = "get => double.Round(OrderItemPallets.Sum(x => x.GrossWeight), 1)")]
|
[ToonDescription(BusinessRule = "get => double.Round(OrderItemPallets.Sum(x => x.NetWeight), 1)")]
|
||||||
public double GrossWeight
|
public double GrossWeight
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
return double.Round(OrderItemPallets.Sum(x => x.GrossWeight), 1);
|
return double.Round(OrderItemPallets.Sum(x => x.NetWeight), 1);
|
||||||
//CommonHelper.To<double>(GenericAttributes.SingleOrDefault(x => x.Key == nameof(IMeasuringGrossWeight.GrossWeight))?.Value ?? "0");
|
//CommonHelper.To<double>(GenericAttributes.SingleOrDefault(x => x.Key == nameof(IMeasuringGrossWeight.GrossWeight))?.Value ?? "0");
|
||||||
}
|
}
|
||||||
set
|
set
|
||||||
|
|
|
||||||
|
|
@ -7,40 +7,21 @@ public static class FruitBankConstClient
|
||||||
{
|
{
|
||||||
public static string DefaultLocale = "en-US";
|
public static string DefaultLocale = "en-US";
|
||||||
|
|
||||||
// DELEGÁT az AcEnv.BaseUrl-re (AyCode.Core framework, 2026-07): az érték EGY helyen, a kereten él
|
// A nop szerver base URL-je. Induláskor a host tölti fel az appsettings "AyCode:Urls:BaseUrl"-ből
|
||||||
// (fail-fast: hidratálatlan olvasás exception). A hostok induláskor hidratálják az appsettings
|
// (ld. Program.cs / MauiProgram.cs). Az fbHub ÉS a loggerHub is EBBŐL az egy értékből származik (+/fbHub, +/loggerHub).
|
||||||
// "AyCode:Urls:BaseUrl"-ből; az fbHub és a loggerHub URL-je is ebbő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.
|
||||||
// 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
|
// 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;
|
||||||
public static string BaseUrl
|
public static string BaseUrl
|
||||||
{
|
{
|
||||||
get => AcEnv.BaseUrl;
|
get => !string.IsNullOrWhiteSpace(_baseUrl)
|
||||||
set => AcEnv.BaseUrl = value;
|
? _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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 DefaultHubName = "fbHub";
|
||||||
public static string LoggerHubName = "loggerHub";
|
public static string LoggerHubName = "loggerHub";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ public interface IFruitBankDataControllerCommon
|
||||||
public Task<EkaerHistory?> AddEkaerHistory(EkaerHistory ekaerHistory);
|
public Task<EkaerHistory?> AddEkaerHistory(EkaerHistory ekaerHistory);
|
||||||
public Task<EkaerHistory?> UpdateEkaerHistory(EkaerHistory ekaerHistory);
|
public Task<EkaerHistory?> UpdateEkaerHistory(EkaerHistory ekaerHistory);
|
||||||
public Task<EkaerHistory?> GenerateEkaerXmlDocument(int ekaerHistoryId);
|
public Task<EkaerHistory?> GenerateEkaerXmlDocument(int ekaerHistoryId);
|
||||||
public Task<EkaerHistory?> SendEkaerToNav(int ekaerHistoryId);
|
|
||||||
public Task<EkaerHistory?> CreateEkaerHistory(int foreignKey, bool isOutgoing);
|
public Task<EkaerHistory?> CreateEkaerHistory(int foreignKey, bool isOutgoing);
|
||||||
public Task<EkaerCreateResult?> CreateMissingEkaerHistories(DateTime fromDate);
|
public Task<EkaerCreateResult?> CreateMissingEkaerHistories(DateTime fromDate);
|
||||||
public Task<int> GetEkaerHistoryCount(EkaerHistoryFilter filter);
|
public Task<int> GetEkaerHistoryCount(EkaerHistoryFilter filter);
|
||||||
|
|
|
||||||
|
|
@ -127,15 +127,11 @@ public class SignalRTags : AcSignalRTags
|
||||||
public const int CreateEkaerHistory = 191;
|
public const int CreateEkaerHistory = 191;
|
||||||
public const int CreateMissingEkaerHistories = 192;
|
public const int CreateMissingEkaerHistories = 192;
|
||||||
public const int GetEkaerHistoryCount = 193;
|
public const int GetEkaerHistoryCount = 193;
|
||||||
public const int SendEkaerToNav = 194;
|
|
||||||
|
|
||||||
public const int AuthenticateUser = 195;
|
public const int AuthenticateUser = 195;
|
||||||
public const int RefreshToken = 200;
|
public const int RefreshToken = 200;
|
||||||
|
|
||||||
public const int IsProdEnvironment = 210;
|
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
|
#region SendToClient
|
||||||
public const int SendOrderChanged = 500;
|
public const int SendOrderChanged = 500;
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,11 @@ namespace FruitBankHybrid.Shared.Tests
|
||||||
private static string GetOriginalName(string name) => name.Replace(Fixture, string.Empty);
|
private static string GetOriginalName(string name) => name.Replace(Fixture, string.Empty);
|
||||||
|
|
||||||
[TestInitialize]
|
[TestInitialize]
|
||||||
public async Task TestInit()
|
public void TestInit()
|
||||||
{
|
{
|
||||||
_signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankClientTests));
|
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
|
||||||
|
|
||||||
// Környezet-guard a szerver saját bevallása alapján (PROD / nem igazolható → exception, fail closed).
|
_signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankClientTests));
|
||||||
await TestServerGuard.EnsureNotProdAsync(_signalRClient);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Shipping
|
#region Shipping
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,11 @@ namespace FruitBankHybrid.Shared.Tests
|
||||||
private FruitBankSignalRClient _signalRClient = null!;
|
private FruitBankSignalRClient _signalRClient = null!;
|
||||||
|
|
||||||
[TestInitialize]
|
[TestInitialize]
|
||||||
public async Task TestInit()
|
public void TestInit()
|
||||||
{
|
{
|
||||||
_signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankEkaerTests));
|
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
|
||||||
|
|
||||||
// Környezet-guard a szerver saját bevallása alapján (PROD / nem igazolható → exception, fail closed).
|
_signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankEkaerTests));
|
||||||
await TestServerGuard.EnsureNotProdAsync(_signalRClient);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#region EkaerHistory / Create
|
#region EkaerHistory / Create
|
||||||
|
|
|
||||||
|
|
@ -76,10 +76,4 @@
|
||||||
<PackageReference Include="Microsoft.Extensions.ObjectPool" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.ObjectPool" Version="9.0.0" />
|
||||||
</ItemGroup>
|
</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>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,11 @@ namespace FruitBankHybrid.Shared.Tests
|
||||||
private static string GetOriginalName(string name) => name.Replace(Fixture, string.Empty);
|
private static string GetOriginalName(string name) => name.Replace(Fixture, string.Empty);
|
||||||
|
|
||||||
[TestInitialize]
|
[TestInitialize]
|
||||||
public async Task TestInit()
|
public void TestInit()
|
||||||
{
|
{
|
||||||
_signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankPartnerTests));
|
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
|
||||||
|
|
||||||
// Környezet-guard a szerver saját bevallása alapján (PROD / nem igazolható → exception, fail closed).
|
_signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankClientTests));
|
||||||
await TestServerGuard.EnsureNotProdAsync(_signalRClient);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Partner
|
#region Partner
|
||||||
|
|
|
||||||
|
|
@ -186,12 +186,11 @@ public sealed class JsonExtensionTests
|
||||||
private FruitBankSignalRClient _signalRClient = null!;
|
private FruitBankSignalRClient _signalRClient = null!;
|
||||||
|
|
||||||
[TestInitialize]
|
[TestInitialize]
|
||||||
public async Task TestInit()
|
public void TestInit()
|
||||||
{
|
{
|
||||||
_signalRClient = TestSignalRClientFactory.Create(nameof(JsonExtensionTests));
|
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
|
||||||
|
|
||||||
// Környezet-guard a szerver saját bevallása alapján (PROD / nem igazolható → exception, fail closed).
|
_signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankClientTests));
|
||||||
await TestServerGuard.EnsureNotProdAsync(_signalRClient);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,11 @@ public sealed class OrderClientTests
|
||||||
private FruitBankSignalRClient _signalRClient = null!;
|
private FruitBankSignalRClient _signalRClient = null!;
|
||||||
|
|
||||||
[TestInitialize]
|
[TestInitialize]
|
||||||
public async Task TestInit()
|
public void TestInit()
|
||||||
{
|
{
|
||||||
_signalRClient = TestSignalRClientFactory.Create(nameof(OrderClientTests));
|
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
|
||||||
|
|
||||||
// Környezet-guard a szerver saját bevallása alapján (PROD / nem igazolható → exception, fail closed).
|
_signalRClient = TestSignalRClientFactory.Create(nameof(OrderClientTests));
|
||||||
await TestServerGuard.EnsureNotProdAsync(_signalRClient);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -99,12 +99,11 @@ public sealed class ToonTests
|
||||||
private FruitBankSignalRClient _signalRClient = null!;
|
private FruitBankSignalRClient _signalRClient = null!;
|
||||||
|
|
||||||
[TestInitialize]
|
[TestInitialize]
|
||||||
public async Task TestInit()
|
public void TestInit()
|
||||||
{
|
{
|
||||||
_signalRClient = TestSignalRClientFactory.Create(nameof(ToonTests));
|
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
|
||||||
|
|
||||||
// Környezet-guard a szerver saját bevallása alapján (PROD / nem igazolható → exception, fail closed).
|
_signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankClientTests));
|
||||||
await TestServerGuard.EnsureNotProdAsync(_signalRClient);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@
|
||||||
|
|
||||||
<DxGridDataColumn FieldName="Created" ReadOnly="true" DisplayFormat="yyyy.MM.dd HH:mm" />
|
<DxGridDataColumn FieldName="Created" ReadOnly="true" DisplayFormat="yyyy.MM.dd HH:mm" />
|
||||||
<DxGridDataColumn FieldName="Modified" ReadOnly="true" DisplayFormat="yyyy.MM.dd HH:mm" />
|
<DxGridDataColumn FieldName="Modified" ReadOnly="true" DisplayFormat="yyyy.MM.dd HH:mm" />
|
||||||
<DxGridDataColumn Caption="Műveletek" Width="290" AllowSort="false" AllowGroup="false">
|
<DxGridDataColumn Caption="Műveletek" Width="200" AllowSort="false" AllowGroup="false">
|
||||||
<CellDisplayTemplate>
|
<CellDisplayTemplate>
|
||||||
@{
|
@{
|
||||||
var row = (EkaerHistory)context.DataItem;
|
var row = (EkaerHistory)context.DataItem;
|
||||||
|
|
@ -89,12 +89,6 @@
|
||||||
Enabled="@(CanDownload(row))"
|
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." })"
|
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)" />
|
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>
|
</CellDisplayTemplate>
|
||||||
</DxGridDataColumn>
|
</DxGridDataColumn>
|
||||||
|
|
@ -225,12 +219,6 @@
|
||||||
private static bool CanDownload(EkaerHistory ekaerHistory)
|
private static bool CanDownload(EkaerHistory ekaerHistory)
|
||||||
=> ekaerHistory.Status.IsSubmittable() && !string.IsNullOrWhiteSpace(ekaerHistory.XmlDoc);
|
=> 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.
|
// 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;
|
private Task NotifyDataChanged() => OnDataChanged.HasDelegate ? OnDataChanged.InvokeAsync() : Task.CompletedTask;
|
||||||
|
|
||||||
|
|
@ -269,50 +257,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
DateTime CreateFromDate { get; set; } = DateTime.Today.AddDays(-3);
|
||||||
private bool _creatingMissing;
|
private bool _creatingMissing;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
using AyCode.Core.Consts;
|
using AyCode.Core.Consts;
|
||||||
using AyCode.Core.Extensions;
|
using AyCode.Core.Extensions;
|
||||||
using AyCode.Core.Helpers;
|
|
||||||
using AyCode.Core.Loggers;
|
using AyCode.Core.Loggers;
|
||||||
using FruitBank.Common;
|
|
||||||
using AyCode.Services.SignalRs;
|
using AyCode.Services.SignalRs;
|
||||||
using AyCode.Utils.Extensions;
|
using AyCode.Utils.Extensions;
|
||||||
using DevExpress.Blazor;
|
using DevExpress.Blazor;
|
||||||
|
|
@ -11,7 +9,6 @@ using FruitBank.Common.Models;
|
||||||
using FruitBank.Common.Models.SignalRs;
|
using FruitBank.Common.Models.SignalRs;
|
||||||
using FruitBank.Common.SignalRs;
|
using FruitBank.Common.SignalRs;
|
||||||
using FruitBankHybrid.Shared.Pages;
|
using FruitBankHybrid.Shared.Pages;
|
||||||
using FruitBankHybrid.Shared.Services;
|
|
||||||
using FruitBankHybrid.Shared.Services.Loggers;
|
using FruitBankHybrid.Shared.Services.Loggers;
|
||||||
using FruitBankHybrid.Shared.Services.SignalRs;
|
using FruitBankHybrid.Shared.Services.SignalRs;
|
||||||
using Mango.Nop.Core.Loggers;
|
using Mango.Nop.Core.Loggers;
|
||||||
|
|
@ -26,7 +23,6 @@ public partial class MainLayout : LayoutComponentBase, IDisposable
|
||||||
[Inject] public required LoggedInModel LoggedInModel { get; set; }
|
[Inject] public required LoggedInModel LoggedInModel { get; set; }
|
||||||
[Inject] IToastNotificationService ToastService { get; set; }
|
[Inject] IToastNotificationService ToastService { get; set; }
|
||||||
[Inject] public required FruitBankSignalRClient FruitBankSignalRClient { 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 EventCallback RefreshMainLayoutEventCallback => EventCallback.Factory.Create(this, RefreshMainLayout);
|
||||||
private NavMenu _navMenu = null!;
|
private NavMenu _navMenu = null!;
|
||||||
|
|
@ -51,16 +47,8 @@ public partial class MainLayout : LayoutComponentBase, IDisposable
|
||||||
LoggedInModel.GetRolesFunc = FruitBankSignalRClient.GetCustomerRolesByCustomerId;
|
LoggedInModel.GetRolesFunc = FruitBankSignalRClient.GetCustomerRolesByCustomerId;
|
||||||
|
|
||||||
FruitBankSignalRClient.OnMessageReceived += SignalRClientOnMessageReceived;
|
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)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (!firstRender) return;
|
if (!firstRender) return;
|
||||||
|
|
@ -144,6 +132,5 @@ public partial class MainLayout : LayoutComponentBase, IDisposable
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
FruitBankSignalRClient.OnMessageReceived -= SignalRClientOnMessageReceived;
|
FruitBankSignalRClient.OnMessageReceived -= SignalRClientOnMessageReceived;
|
||||||
FruitBankConstClient.IsProdChanged -= OnIsProdChanged;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -45,14 +45,6 @@ namespace FruitBankHybrid.Shared.Services.SignalRs
|
||||||
DataSerializerType = AcSerializerType.Binary,
|
DataSerializerType = AcSerializerType.Binary,
|
||||||
RawResponseData = data
|
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);
|
await OnMessageReceived(messageTag, responseDataMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,85 +58,7 @@ namespace FruitBankHybrid.Shared.Services.SignalRs
|
||||||
|
|
||||||
public Task<List<MeasuringModel>?> GetMeasuringModels() => GetAllAsync<List<MeasuringModel>>(SignalRTags.GetMeasuringModels);
|
public Task<List<MeasuringModel>?> GetMeasuringModels() => GetAllAsync<List<MeasuringModel>>(SignalRTags.GetMeasuringModels);
|
||||||
|
|
||||||
/// <summary>
|
public Task<bool> IsProdEnvironment() => GetAllAsync<bool>(SignalRTags.IsProdEnvironment);
|
||||||
/// FAIL CLOSED: a nyers SignalResponseDataMessage úton kérdez — timeout/hiba esetén EXCEPTION, nem default(false).
|
|
||||||
/// (A sima GetAllAsync<bool> 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
|
#region Partner
|
||||||
public Task<List<Partner>?> GetPartners() => GetAllAsync<List<Partner>>(SignalRTags.GetPartners);
|
public Task<List<Partner>?> GetPartners() => GetAllAsync<List<Partner>>(SignalRTags.GetPartners);
|
||||||
|
|
@ -168,7 +82,6 @@ namespace FruitBankHybrid.Shared.Services.SignalRs
|
||||||
public Task<EkaerHistory?> AddEkaerHistory(EkaerHistory ekaerHistory) => PostDataAsync(SignalRTags.AddEkaerHistory, ekaerHistory);
|
public Task<EkaerHistory?> AddEkaerHistory(EkaerHistory ekaerHistory) => PostDataAsync(SignalRTags.AddEkaerHistory, ekaerHistory);
|
||||||
public Task<EkaerHistory?> UpdateEkaerHistory(EkaerHistory ekaerHistory) => PostDataAsync(SignalRTags.UpdateEkaerHistory, 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?> 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<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<EkaerCreateResult?> CreateMissingEkaerHistories(DateTime fromDate) => GetByIdAsync<EkaerCreateResult?>(SignalRTags.CreateMissingEkaerHistories, fromDate);
|
||||||
public Task<int> GetEkaerHistoryCount(EkaerHistoryFilter filter) => GetByIdAsync<int>(SignalRTags.GetEkaerHistoryCount, filter);
|
public Task<int> GetEkaerHistoryCount(EkaerHistoryFilter filter) => GetByIdAsync<int>(SignalRTags.GetEkaerHistoryCount, filter);
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
|
||||||
// Hub-URL-ek EGY forrásból: az fbHub és a loggerHub is a BaseUrl-ből származik
|
// Hub-URL-ek EGY forrásból: az fbHub és a loggerHub is az AyCode:Urls:BaseUrl-ből származik.
|
||||||
// (a FruitBankConstClient.BaseUrl az AcEnv.BaseUrl keret-állapotra delegál — fail-fast).
|
// A loggerHub writer a FruitBankConstClient.BaseUrl-t olvassa, ezért induláskor onnan hidratáljuk.
|
||||||
FruitBankConstClient.BaseUrl = builder.Configuration["AyCode:Urls:BaseUrl"]
|
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.");
|
?? throw new InvalidOperationException("Hiányzik az 'AyCode:Urls:BaseUrl' az appsettings-ből — a hub-ok URL-je ebből származik.");
|
||||||
|
|
||||||
|
|
@ -28,7 +28,6 @@ builder.Services.AddDevExpressBlazor(configure => configure.SizeMode = DevExpres
|
||||||
// Add device-specific services used by the FruitBankHybrid.Shared project
|
// Add device-specific services used by the FruitBankHybrid.Shared project
|
||||||
builder.Services.AddSingleton<IFormFactor, FormFactor>();
|
builder.Services.AddSingleton<IFormFactor, FormFactor>();
|
||||||
builder.Services.AddSingleton<ISecureCredentialService, WebSecureCredentialService>();
|
builder.Services.AddSingleton<ISecureCredentialService, WebSecureCredentialService>();
|
||||||
builder.Services.AddSingleton<IAppTitleService, WebAppTitleService>();
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
builder.Services.AddSingleton<IAcLogWriterClientBase, BrowserConsoleLogWriter>();
|
builder.Services.AddSingleton<IAcLogWriterClientBase, BrowserConsoleLogWriter>();
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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'");
|
|
||||||
}
|
|
||||||
|
|
@ -17,8 +17,7 @@ using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Hub-URL-ek EGY forrásból: az fbHub és a loggerHub is a BaseUrl-ből származik
|
// Hub-URL-ek EGY forrásból: az fbHub és a loggerHub is az AyCode:Urls:BaseUrl-ből származik.
|
||||||
// (a FruitBankConstClient.BaseUrl az AcEnv.BaseUrl keret-állapotra delegál — fail-fast).
|
|
||||||
FruitBankConstClient.BaseUrl = builder.Configuration["AyCode:Urls:BaseUrl"]
|
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.");
|
?? throw new InvalidOperationException("Hiányzik az 'AyCode:Urls:BaseUrl' az appsettings-ből — a hub-ok URL-je ebből származik.");
|
||||||
|
|
||||||
|
|
@ -28,7 +27,6 @@ builder.Services.AddMvc();
|
||||||
|
|
||||||
builder.Services.AddSingleton<IFormFactor, FormFactor>();
|
builder.Services.AddSingleton<IFormFactor, FormFactor>();
|
||||||
builder.Services.AddSingleton<ISecureCredentialService, ServerSecureCredentialService>();
|
builder.Services.AddSingleton<ISecureCredentialService, ServerSecureCredentialService>();
|
||||||
builder.Services.AddSingleton<IAppTitleService, ServerAppTitleService>();
|
|
||||||
|
|
||||||
builder.Services.AddSingleton<IAcLogWriterBase, ConsoleLogWriter>();
|
builder.Services.AddSingleton<IAcLogWriterBase, ConsoleLogWriter>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
"AcHubConnection": {
|
"AcHubConnection": {
|
||||||
// Az Url innen KIVÉVE: az fbHub és a loggerHub is az AyCode:Urls:BaseUrl-ből származik (kódban: +/fbHub, +/loggerHub).
|
// Az Url innen KIVÉVE: az fbHub és a loggerHub is az AyCode:Urls:BaseUrl-ből származik (kódban: +/fbHub, +/loggerHub).
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,7 @@ namespace FruitBankHybrid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hub-URL-ek EGY forrásból: az fbHub és a loggerHub is a BaseUrl-ből származik
|
// Hub-URL-ek EGY forrásból: az fbHub és a loggerHub is az AyCode:Urls:BaseUrl-ből származik.
|
||||||
// (a FruitBankConstClient.BaseUrl az AcEnv.BaseUrl keret-állapotra delegál — fail-fast).
|
|
||||||
FruitBankConstClient.BaseUrl = builder.Configuration["AyCode:Urls:BaseUrl"]
|
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.");
|
?? throw new InvalidOperationException("Hiányzik az 'AyCode:Urls:BaseUrl' az appsettings-ből — a hub-ok URL-je ebből származik.");
|
||||||
|
|
||||||
|
|
@ -76,7 +75,6 @@ namespace FruitBankHybrid
|
||||||
// Add device-specific services used by the FruitBankHybrid.Shared project
|
// Add device-specific services used by the FruitBankHybrid.Shared project
|
||||||
builder.Services.AddSingleton<IFormFactor, FormFactor>();
|
builder.Services.AddSingleton<IFormFactor, FormFactor>();
|
||||||
builder.Services.AddSingleton<ISecureCredentialService, MauiSecureCredentialService>();
|
builder.Services.AddSingleton<ISecureCredentialService, MauiSecureCredentialService>();
|
||||||
builder.Services.AddSingleton<IAppTitleService, MauiAppTitleService>();
|
|
||||||
|
|
||||||
builder.Services.AddSingleton<LoggedInModel>(sp => new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>()));
|
builder.Services.AddSingleton<LoggedInModel>(sp => new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>()));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,324 +0,0 @@
|
||||||
# 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 0–9999, VeggieVoyage 10000–19999, 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
|
|
@ -1,11 +0,0 @@
|
||||||
# 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) |
|
|
||||||
Loading…
Reference in New Issue