Compare commits
10 Commits
27ca80fd54
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cc1c3a423 | |||
| b0896dc777 | |||
| f368765419 | |||
| 197f6f2d38 | |||
| 7d5af0c81b | |||
| 9e47a4e60c | |||
| cb57c61a11 | |||
| 67450393fc | |||
| ae5c750d34 | |||
| f985deb233 |
@@ -83,6 +83,14 @@ SEED_ADMIN_PASSWORD=change-me-strong-admin-password
|
||||
ZARINPAL_MERCHANT_ID=
|
||||
ZARINPAL_SANDBOX=false
|
||||
|
||||
# ── Payment: FlatRender Pay (ZarinPal broker) ─────────────────────────────────
|
||||
# Broker keys from the FlatRender dashboard. Webhook is registered at the broker as
|
||||
# https://api.meezi.ir/api/payment/webhook. Keep the live secret OUT of git.
|
||||
FLATPAY_API_KEY=
|
||||
FLATPAY_SECRET=
|
||||
FLATPAY_BASE_URL=https://pay.flatrender.ir
|
||||
FLATPAY_RETURN_URL=https://meezi.ir/payment/return
|
||||
|
||||
# ── SMS: Kavenegar ────────────────────────────────────────────────────────────
|
||||
# Empty = OTP is logged to API console (fine for dev, not for production)
|
||||
KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F43334672576B526F5A4B4B795665493D
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Meezi.PrintAgent;
|
||||
|
||||
/// <summary>Persisted agent identity — written to %APPDATA%\MeeziPrintAgent\config.json.</summary>
|
||||
public class AgentConfig
|
||||
{
|
||||
/// <summary>Origin of the Meezi API, e.g. https://app.meezi.ir.</summary>
|
||||
public string? ApiBaseUrl { get; set; }
|
||||
public string? Token { get; set; }
|
||||
public string? CafeId { get; set; }
|
||||
public string? AgentId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
|
||||
private static string Dir =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MeeziPrintAgent");
|
||||
private static string FilePath => Path.Combine(Dir, "config.json");
|
||||
|
||||
public bool IsPaired => !string.IsNullOrWhiteSpace(Token) && !string.IsNullOrWhiteSpace(ApiBaseUrl);
|
||||
|
||||
public static AgentConfig Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(FilePath))
|
||||
return JsonSerializer.Deserialize<AgentConfig>(File.ReadAllText(FilePath)) ?? new AgentConfig();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// corrupt/unreadable config → start fresh
|
||||
}
|
||||
return new AgentConfig();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
Directory.CreateDirectory(Dir);
|
||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<!-- Windows-only: uses winspool (raw printing) + WMI (printer discovery).
|
||||
Overrides the repo-wide net10.0 / central package management on purpose so
|
||||
this app stays independent of the API build (CI never compiles it). -->
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>Meezi.PrintAgent</RootNamespace>
|
||||
<AssemblyName>MeeziPrintAgent</AssemblyName>
|
||||
<Version>0.1.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
|
||||
<PackageReference Include="System.Management" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace Meezi.PrintAgent;
|
||||
|
||||
/// <summary>A host on the café LAN answering on a probed port. Property names match
|
||||
/// the cloud's <c>DiscoveredDevice</c> record so SignalR maps them across.</summary>
|
||||
public record ScannedDevice(string Ip, int Port, string Kind);
|
||||
|
||||
/// <summary>
|
||||
/// Scans the agent PC's local /24 subnet(s) for hosts answering on the given TCP
|
||||
/// ports — used to auto-find network printers (:9100) and card terminals (:8088)
|
||||
/// so the café owner doesn't have to type IP addresses.
|
||||
/// </summary>
|
||||
public static class NetworkScanner
|
||||
{
|
||||
private const int MaxConcurrency = 128;
|
||||
private const int ConnectTimeoutMs = 300;
|
||||
|
||||
public static async Task<List<ScannedDevice>> ScanAsync(string portsCsv, CancellationToken ct)
|
||||
{
|
||||
var ports = portsCsv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(p => int.TryParse(p, out var n) ? n : 0)
|
||||
.Where(n => n is > 0 and <= 65535)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (ports.Count == 0) ports = [9100, 8088];
|
||||
|
||||
var results = new ConcurrentBag<ScannedDevice>();
|
||||
using var gate = new SemaphoreSlim(MaxConcurrency);
|
||||
var tasks = new List<Task>();
|
||||
|
||||
foreach (var prefix in LocalSubnets())
|
||||
{
|
||||
for (var host = 1; host <= 254; host++)
|
||||
{
|
||||
var ip = $"{prefix}.{host}";
|
||||
foreach (var port in ports)
|
||||
{
|
||||
await gate.WaitAsync(ct);
|
||||
tasks.Add(Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await CanConnectAsync(ip, port))
|
||||
results.Add(new ScannedDevice(ip, port, Classify(port)));
|
||||
}
|
||||
finally { gate.Release(); }
|
||||
}, ct));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
return results
|
||||
.DistinctBy(d => $"{d.Ip}:{d.Port}")
|
||||
.OrderBy(d => d.Ip)
|
||||
.ThenBy(d => d.Port)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Distinct /24 prefixes of this PC's up, non-loopback IPv4 interfaces.</summary>
|
||||
private static IEnumerable<string> LocalSubnets()
|
||||
{
|
||||
var seen = new HashSet<string>();
|
||||
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
||||
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
|
||||
{
|
||||
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
|
||||
if (IPAddress.IsLoopback(ua.Address)) continue;
|
||||
var b = ua.Address.GetAddressBytes();
|
||||
var prefix = $"{b[0]}.{b[1]}.{b[2]}";
|
||||
if (seen.Add(prefix)) yield return prefix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> CanConnectAsync(string ip, int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var connect = client.ConnectAsync(ip, port);
|
||||
var done = await Task.WhenAny(connect, Task.Delay(ConnectTimeoutMs));
|
||||
if (done != connect) return false;
|
||||
await connect; // observe exceptions
|
||||
return client.Connected;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Classify(int port) => port switch
|
||||
{
|
||||
9100 => "network-printer",
|
||||
8088 => "pos-terminal",
|
||||
_ => "other",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Meezi.PrintAgent;
|
||||
|
||||
/// <summary>Redeems a one-time pairing code for a long-lived agent token.</summary>
|
||||
public static class Pairing
|
||||
{
|
||||
private record ClaimReq(string code, string? name, string? machineName);
|
||||
private record ApiEnvelope<T>(bool success, T? data);
|
||||
private record ClaimData(string agentId, string token, string cafeId, string agentName);
|
||||
|
||||
public static async Task<AgentConfig?> ClaimAsync(string apiBaseUrl, string code, string name)
|
||||
{
|
||||
using var http = new HttpClient { BaseAddress = new Uri(apiBaseUrl), Timeout = TimeSpan.FromSeconds(20) };
|
||||
HttpResponseMessage resp;
|
||||
try
|
||||
{
|
||||
resp = await http.PostAsJsonAsync("/api/print-agent/claim",
|
||||
new ClaimReq(code, name, Environment.MachineName));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" network error: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var env = await resp.Content.ReadFromJsonAsync<ApiEnvelope<ClaimData>>();
|
||||
if (env?.success != true || env.data is null)
|
||||
return null;
|
||||
|
||||
return new AgentConfig
|
||||
{
|
||||
ApiBaseUrl = apiBaseUrl.TrimEnd('/'),
|
||||
Token = env.data.token,
|
||||
CafeId = env.data.cafeId,
|
||||
AgentId = env.data.agentId,
|
||||
Name = env.data.agentName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Meezi.PrintAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Relays a card-terminal payment on the café LAN. The cloud can't reach the
|
||||
/// terminal's private IP, so it hands the agent the amount and the terminal's
|
||||
/// ip:port; the agent POSTs to the terminal's local HTTP <c>/pay</c> endpoint and
|
||||
/// reports back whether it was approved.
|
||||
/// </summary>
|
||||
public static class PosTerminal
|
||||
{
|
||||
// A card payment blocks on the customer inserting/approving — allow plenty.
|
||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(90) };
|
||||
|
||||
public static async Task<(bool Ok, string? Error)> SendPaymentAsync(
|
||||
string ip, int port, long amount, string orderId, CancellationToken ct)
|
||||
{
|
||||
var url = $"http://{ip}:{port}/pay";
|
||||
try
|
||||
{
|
||||
using var resp = await Http.PostAsJsonAsync(url, new { amount, orderId }, ct);
|
||||
if (resp.IsSuccessStatusCode) return (true, null);
|
||||
return (false, $"POS_DEVICE_REJECTED:HTTP {(int)resp.StatusCode}");
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return (false, "POS_DEVICE_TIMEOUT");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"POS_DEVICE_CONNECTION_FAILED:{ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Management;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace Meezi.PrintAgent;
|
||||
|
||||
/// <summary>One printer the agent can reach. SystemName is what it prints to (the
|
||||
/// Windows printer name, or "ip:port" for a raw network device).</summary>
|
||||
public record DiscoveredPrinter(string SystemName, string DisplayName, string Kind);
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public static class PrinterDiscovery
|
||||
{
|
||||
/// <summary>Every printer installed on this PC (USB and network-with-driver alike).</summary>
|
||||
public static List<DiscoveredPrinter> Discover()
|
||||
{
|
||||
var list = new List<DiscoveredPrinter>();
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Name, PortName, Network FROM Win32_Printer");
|
||||
foreach (var o in searcher.Get())
|
||||
{
|
||||
using var p = (ManagementObject)o;
|
||||
var name = p["Name"]?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||
var port = p["PortName"]?.ToString() ?? "";
|
||||
var network = p["Network"] as bool? ?? false;
|
||||
list.Add(new DiscoveredPrinter(name!, name!, ClassifyKind(port, network)));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// WMI unavailable — report nothing rather than crash.
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static string ClassifyKind(string port, bool network)
|
||||
{
|
||||
var up = port.ToUpperInvariant();
|
||||
if (up.StartsWith("USB") || up.StartsWith("DOT4")) return "usb";
|
||||
if (network || up.StartsWith("IP_") || up.StartsWith("WSD") || up.Contains(':')) return "network";
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using Meezi.PrintAgent;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
Console.WriteLine("=== Meezi Print Agent (پرینتسرور میزی) ===");
|
||||
|
||||
var config = AgentConfig.Load();
|
||||
var wantsPair = args.Length > 0 && args[0].Equals("pair", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!config.IsPaired || wantsPair)
|
||||
{
|
||||
var paired = await PairInteractiveAsync(config);
|
||||
if (paired is null)
|
||||
{
|
||||
Console.WriteLine("Pairing cancelled or failed.");
|
||||
return 1;
|
||||
}
|
||||
paired.Save();
|
||||
config = paired;
|
||||
Console.WriteLine($"✓ Paired as '{config.Name}'. Configuration saved.");
|
||||
}
|
||||
|
||||
await RunAsync(config);
|
||||
return 0;
|
||||
|
||||
static async Task<AgentConfig?> PairInteractiveAsync(AgentConfig existing)
|
||||
{
|
||||
var defaultUrl = existing.ApiBaseUrl ?? "https://app.meezi.ir";
|
||||
Console.Write($"Meezi API URL [{defaultUrl}]: ");
|
||||
var url = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(url)) url = defaultUrl;
|
||||
|
||||
Console.Write("Pairing code (from Dashboard → Settings → Printers): ");
|
||||
var code = Console.ReadLine()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(code)) return null;
|
||||
|
||||
Console.Write($"Name for this PC [{Environment.MachineName}]: ");
|
||||
var name = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(name)) name = Environment.MachineName;
|
||||
|
||||
Console.WriteLine("Pairing…");
|
||||
var cfg = await Pairing.ClaimAsync(url!, code!, name!);
|
||||
if (cfg is null) Console.WriteLine(" Invalid/expired code, or the URL is wrong.");
|
||||
return cfg;
|
||||
}
|
||||
|
||||
static async Task RunAsync(AgentConfig config)
|
||||
{
|
||||
var hubUrl = $"{config.ApiBaseUrl!.TrimEnd('/')}/hubs/print-agent" +
|
||||
$"?access_token={Uri.EscapeDataString(config.Token!)}";
|
||||
|
||||
var connection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect(new ForeverRetry())
|
||||
.Build();
|
||||
|
||||
connection.On<string, string, string>("PrintJob", async (jobId, printerSystemName, base64) =>
|
||||
{
|
||||
var ok = false;
|
||||
string? err = null;
|
||||
try
|
||||
{
|
||||
var data = Convert.FromBase64String(base64);
|
||||
await RawPrinter.PrintAsync(printerSystemName, data, CancellationToken.None);
|
||||
ok = true;
|
||||
Console.WriteLine($"[print] {data.Length} bytes → {printerSystemName} ✓");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
err = ex.Message;
|
||||
Console.WriteLine($"[print] {printerSystemName} ✗ {ex.Message}");
|
||||
}
|
||||
try { await connection.InvokeAsync("JobResult", jobId, ok, err); } catch { /* ack best-effort */ }
|
||||
});
|
||||
|
||||
// Cloud → agent: relay a card-terminal payment to the terminal on the LAN.
|
||||
connection.On<string, string, int, long, string>("PaymentRequest", async (requestId, ip, port, amount, orderId) =>
|
||||
{
|
||||
var (ok, err) = await PosTerminal.SendPaymentAsync(ip, port, amount, orderId, CancellationToken.None);
|
||||
Console.WriteLine(ok
|
||||
? $"[pay] {amount} → {ip}:{port} ✓"
|
||||
: $"[pay] {ip}:{port} ✗ {err}");
|
||||
try { await connection.InvokeAsync("PaymentResult", requestId, ok, err); } catch { /* ack best-effort */ }
|
||||
});
|
||||
|
||||
// Cloud → agent: scan the LAN for hosts on the given ports (printers :9100, terminals :8088).
|
||||
connection.On<string, string>("ScanNetwork", async (requestId, ports) =>
|
||||
{
|
||||
List<ScannedDevice> found;
|
||||
try { found = await NetworkScanner.ScanAsync(ports, CancellationToken.None); }
|
||||
catch (Exception ex) { Console.WriteLine($"[scan] failed: {ex.Message}"); found = []; }
|
||||
Console.WriteLine($"[scan] ports={ports} → {found.Count} host(s): " +
|
||||
string.Join(", ", found.Select(d => $"{d.Ip}:{d.Port}")));
|
||||
try { await connection.InvokeAsync("ReportScan", requestId, found); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
connection.Reconnected += async _ =>
|
||||
{
|
||||
Console.WriteLine("[hub] reconnected");
|
||||
await SafeReportAsync(connection);
|
||||
};
|
||||
connection.Closed += _ =>
|
||||
{
|
||||
Console.WriteLine("[hub] connection closed");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
await ConnectWithRetryAsync(connection);
|
||||
Console.WriteLine("[hub] connected");
|
||||
await SafeReportAsync(connection);
|
||||
|
||||
// Heartbeat + re-report every 2 minutes (printers added/removed get picked up).
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(2));
|
||||
while (await timer.WaitForNextTickAsync())
|
||||
{
|
||||
try
|
||||
{
|
||||
await connection.InvokeAsync("Heartbeat");
|
||||
await SafeReportAsync(connection);
|
||||
}
|
||||
catch { /* will recover on reconnect */ }
|
||||
}
|
||||
});
|
||||
|
||||
Console.WriteLine("Agent running. Leave this window open. Press Ctrl+C to quit.");
|
||||
await Task.Delay(Timeout.Infinite);
|
||||
}
|
||||
|
||||
static async Task SafeReportAsync(HubConnection connection)
|
||||
{
|
||||
try
|
||||
{
|
||||
var printers = PrinterDiscovery.Discover();
|
||||
await connection.InvokeAsync("ReportPrinters", printers);
|
||||
Console.WriteLine($"[printers] reported {printers.Count}: " +
|
||||
string.Join(", ", printers.Select(p => $"{p.DisplayName} ({p.Kind})")));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[printers] report failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task ConnectWithRetryAsync(HubConnection connection)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
try { await connection.StartAsync(); return; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[hub] connect failed: {ex.Message}; retrying in 5s");
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retry reconnecting forever with capped exponential backoff.</summary>
|
||||
sealed class ForeverRetry : IRetryPolicy
|
||||
{
|
||||
public TimeSpan? NextRetryDelay(RetryContext ctx) =>
|
||||
TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, Math.Min(ctx.PreviousRetryCount, 5))));
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace Meezi.PrintAgent;
|
||||
|
||||
/// <summary>Writes raw ESC/POS bytes to a printer — by Windows name (winspool RAW
|
||||
/// passthrough) or to an "ip:port" endpoint (raw TCP).</summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public static class RawPrinter
|
||||
{
|
||||
public static async Task PrintAsync(string systemName, byte[] data, CancellationToken ct)
|
||||
{
|
||||
if (TryParseEndpoint(systemName, out var ip, out var port))
|
||||
{
|
||||
await PrintTcpAsync(ip, port, data, ct);
|
||||
return;
|
||||
}
|
||||
if (!SendBytesToPrinter(systemName, data))
|
||||
throw new Exception($"winspool write failed (last error {Marshal.GetLastWin32Error()})");
|
||||
}
|
||||
|
||||
private static bool TryParseEndpoint(string s, out string ip, out int port)
|
||||
{
|
||||
ip = "";
|
||||
port = 9100;
|
||||
var idx = s.LastIndexOf(':');
|
||||
if (idx <= 0) return false;
|
||||
var host = s[..idx];
|
||||
if (!host.Contains('.')) return false; // not an IPv4-ish host → treat as printer name
|
||||
if (int.TryParse(s[(idx + 1)..], out var p)) port = p;
|
||||
ip = host;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task PrintTcpAsync(string ip, int port, byte[] data, CancellationToken ct)
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(8));
|
||||
await client.ConnectAsync(ip, port, cts.Token);
|
||||
await using var stream = client.GetStream();
|
||||
await stream.WriteAsync(data, cts.Token);
|
||||
await stream.FlushAsync(cts.Token);
|
||||
}
|
||||
|
||||
// ── winspool raw printing ────────────────────────────────────────────────
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct DOCINFOW
|
||||
{
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string pDocName;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string? pOutputFile;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string pDataType;
|
||||
}
|
||||
|
||||
[DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern bool OpenPrinter(string src, out IntPtr hPrinter, IntPtr pd);
|
||||
[DllImport("winspool.drv", SetLastError = true)]
|
||||
private static extern bool ClosePrinter(IntPtr hPrinter);
|
||||
[DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern bool StartDocPrinter(IntPtr hPrinter, int level, ref DOCINFOW di);
|
||||
[DllImport("winspool.drv", SetLastError = true)]
|
||||
private static extern bool EndDocPrinter(IntPtr hPrinter);
|
||||
[DllImport("winspool.drv", SetLastError = true)]
|
||||
private static extern bool StartPagePrinter(IntPtr hPrinter);
|
||||
[DllImport("winspool.drv", SetLastError = true)]
|
||||
private static extern bool EndPagePrinter(IntPtr hPrinter);
|
||||
[DllImport("winspool.drv", SetLastError = true)]
|
||||
private static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBytes, int dwCount, out int dwWritten);
|
||||
|
||||
private static bool SendBytesToPrinter(string printerName, byte[] bytes)
|
||||
{
|
||||
if (!OpenPrinter(printerName, out var hPrinter, IntPtr.Zero)) return false;
|
||||
try
|
||||
{
|
||||
var di = new DOCINFOW { pDocName = "Meezi Receipt", pDataType = "RAW" };
|
||||
if (!StartDocPrinter(hPrinter, 1, ref di)) return false;
|
||||
try
|
||||
{
|
||||
if (!StartPagePrinter(hPrinter)) return false;
|
||||
var ptr = Marshal.AllocHGlobal(bytes.Length);
|
||||
try
|
||||
{
|
||||
Marshal.Copy(bytes, 0, ptr, bytes.Length);
|
||||
if (!WritePrinter(hPrinter, ptr, bytes.Length, out _)) return false;
|
||||
}
|
||||
finally { Marshal.FreeHGlobal(ptr); }
|
||||
EndPagePrinter(hPrinter);
|
||||
}
|
||||
finally { EndDocPrinter(hPrinter); }
|
||||
}
|
||||
finally { ClosePrinter(hPrinter); }
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Meezi Print Agent (پرینتسرور میزی)
|
||||
|
||||
A tiny Windows background app that lets the **cloud-hosted** Meezi reach printers on
|
||||
the café's **local network** (USB or Wi-Fi/Ethernet). The cloud can't open a
|
||||
connection to a `192.168.x.x` or USB printer directly — this agent runs on the cash
|
||||
PC (which *is* on that network), connects **outward** to Meezi over SignalR, reports
|
||||
the printers it can see, and prints the jobs the cloud sends it.
|
||||
|
||||
```
|
||||
Cloud API ──SignalR(out)──► Print Agent (cash PC) ──► USB / LAN printers
|
||||
```
|
||||
|
||||
## How it works
|
||||
1. In the dashboard: **Settings → Printers → Add print server** → you get a pairing code.
|
||||
2. Run the agent on the cash PC, enter the code once. It saves a token to
|
||||
`%APPDATA%\MeeziPrintAgent\config.json` and connects.
|
||||
3. It reports every printer installed on that PC. Back in the dashboard you map
|
||||
*receipt / kitchen / bar* to a printer from the dropdown — no IP typing.
|
||||
4. When Meezi prints, the bytes (ESC/POS) are relayed to the agent, which writes them
|
||||
raw to the chosen printer (`winspool` for installed printers, raw TCP for
|
||||
`ip:port` devices).
|
||||
|
||||
## Build & run (dev)
|
||||
Requires the .NET 10 SDK on Windows.
|
||||
|
||||
```sh
|
||||
# restore via the Nexus mirror (nuget.org is blocked on this network)
|
||||
dotnet restore agent/Meezi.PrintAgent/Meezi.PrintAgent.csproj -s https://mirror.soroushasadi.com/repository/nuget-group/
|
||||
dotnet run --project agent/Meezi.PrintAgent # first run prompts to pair
|
||||
dotnet run --project agent/Meezi.PrintAgent -- pair # re-pair later
|
||||
```
|
||||
|
||||
## Publish a single .exe for cafés
|
||||
```sh
|
||||
dotnet publish agent/Meezi.PrintAgent -c Release -r win-x64 \
|
||||
-p:PublishSingleFile=true --self-contained true -o dist/agent
|
||||
# → dist/agent/MeeziPrintAgent.exe
|
||||
```
|
||||
|
||||
## Notes / roadmap
|
||||
- **Not part of the API solution or CI** — it targets `net10.0-windows` and builds on its own.
|
||||
- Console MVP today. Next: system-tray UI, run-at-login (Task Scheduler / service), auto-update, and an optional LAN scan for raw `ip:9100` printers that aren't installed in Windows.
|
||||
- The token is bearer-equivalent — keep `config.json` on a trusted machine. Revoke from the dashboard if a PC is lost.
|
||||
@@ -94,6 +94,10 @@ services:
|
||||
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
|
||||
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
||||
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
|
||||
FlatPay__ApiKey: "${FLATPAY_API_KEY:-}"
|
||||
FlatPay__Secret: "${FLATPAY_SECRET:-}"
|
||||
FlatPay__BaseUrl: "${FLATPAY_BASE_URL:-https://pay.flatrender.ir}"
|
||||
FlatPay__ReturnUrl: "${FLATPAY_RETURN_URL:-https://meezi.ir/payment/return}"
|
||||
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
|
||||
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
|
||||
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
|
||||
|
||||
@@ -91,6 +91,14 @@ public class BranchPrintSettingsController : CafeApiControllerBase
|
||||
: request.PosDeviceIp.Trim();
|
||||
if (request.PosDevicePort.HasValue)
|
||||
branch.PosDevicePort = request.PosDevicePort.Value;
|
||||
if (request.ReceiptPrintDeviceId is not null)
|
||||
branch.ReceiptPrintDeviceId = string.IsNullOrWhiteSpace(request.ReceiptPrintDeviceId)
|
||||
? null
|
||||
: request.ReceiptPrintDeviceId;
|
||||
if (request.KitchenPrintDeviceId is not null)
|
||||
branch.KitchenPrintDeviceId = string.IsNullOrWhiteSpace(request.KitchenPrintDeviceId)
|
||||
? null
|
||||
: request.KitchenPrintDeviceId;
|
||||
|
||||
branch.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
@@ -110,5 +118,7 @@ public class BranchPrintSettingsController : CafeApiControllerBase
|
||||
b.ReceiptFooter,
|
||||
b.WifiPassword,
|
||||
b.PosDeviceIp,
|
||||
b.PosDevicePort);
|
||||
b.PosDevicePort,
|
||||
b.ReceiptPrintDeviceId,
|
||||
b.KitchenPrintDeviceId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Payments;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.API.Services.Payments;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
/// <summary>FlatRender Pay (ZarinPal broker) checkout + webhook.</summary>
|
||||
[ApiController]
|
||||
public class PaymentController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IBillingService _billing;
|
||||
private readonly IFlatPayService _flatPay;
|
||||
private readonly ILogger<PaymentController> _logger;
|
||||
|
||||
public PaymentController(IBillingService billing, IFlatPayService flatPay, ILogger<PaymentController> logger)
|
||||
{
|
||||
_billing = billing;
|
||||
_flatPay = flatPay;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Start a FlatPay checkout for a plan bundle; returns the URL to redirect the buyer to.</summary>
|
||||
[Authorize]
|
||||
[HttpPost("api/payment/request")]
|
||||
public async Task<IActionResult> CreatePayment(
|
||||
[FromBody] PaymentRequestDto request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsurePermission(tenant, Permission.ManageBilling) is { } permDenied) return permDenied;
|
||||
if (string.IsNullOrEmpty(tenant.CafeId)) return Unauthorized();
|
||||
|
||||
if (request?.ProductId is null || !TryParseProduct(request.ProductId, out var tier, out var months))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("INVALID_PRODUCT", "productId must be a \"Tier:Months\" bundle, e.g. \"Pro:12\".")));
|
||||
|
||||
var (paymentId, amountToman, code, message) =
|
||||
await _billing.CreateFlatPayOrderAsync(tenant.CafeId, tier, months, ct);
|
||||
if (paymentId is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
|
||||
|
||||
var description = $"میزی — اشتراک {tier} ({months} ماه)";
|
||||
var url = await _flatPay.RequestAsync(
|
||||
tenant.CafeId, request.ProductId, (long)amountToman, description, paymentId, ct);
|
||||
|
||||
if (string.IsNullOrEmpty(url))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("PAYMENT_FAILED", "Could not start the payment.")));
|
||||
|
||||
return Ok(new ApiResponse<PaymentRequestResponse>(true, new PaymentRequestResponse(url, paymentId)));
|
||||
}
|
||||
|
||||
/// <summary>Broker → us. Security is the HMAC signature (no user auth). Always 200 after a valid
|
||||
/// signature so the broker doesn't retry a job we've accepted.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("api/payment/webhook")]
|
||||
public async Task<IActionResult> Webhook(CancellationToken ct)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await Request.Body.CopyToAsync(ms, ct);
|
||||
var raw = ms.ToArray();
|
||||
|
||||
var signature = Request.Headers["X-FlatPay-Signature"].ToString();
|
||||
if (!_flatPay.VerifyWebhook(raw, signature))
|
||||
return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var status = GetString(root, "status");
|
||||
var brokerId = GetString(root, "id") ?? GetString(root, "payment_id");
|
||||
|
||||
if (string.Equals(status, "Paid", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrEmpty(brokerId)
|
||||
&& _flatPay.TryMarkProcessed(brokerId))
|
||||
{
|
||||
var meta = root.TryGetProperty("metadata", out var m) && m.ValueKind == JsonValueKind.Object
|
||||
? m
|
||||
: default;
|
||||
var paymentId = GetString(meta, "payment_id");
|
||||
|
||||
if (!string.IsNullOrEmpty(paymentId))
|
||||
await _billing.CompleteFlatPayAsync(paymentId, brokerId, ct);
|
||||
else
|
||||
_logger.LogWarning("FlatPay webhook Paid but missing metadata.payment_id (broker id {Id})", brokerId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "FlatPay webhook processing error");
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>Parse a "Tier:Months" product id, e.g. "Pro:12" → (PlanTier.Pro, 12).</summary>
|
||||
private static bool TryParseProduct(string productId, out PlanTier tier, out int months)
|
||||
{
|
||||
tier = default;
|
||||
months = 0;
|
||||
var parts = productId.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2) return false;
|
||||
return Enum.TryParse(parts[0], ignoreCase: true, out tier)
|
||||
&& tier != PlanTier.Free
|
||||
&& int.TryParse(parts[1], out months)
|
||||
&& months > 0;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement el, string name)
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var v))
|
||||
return null;
|
||||
return v.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => v.GetString(),
|
||||
JsonValueKind.Number => v.ToString(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Hubs;
|
||||
using Meezi.API.Models.Printing;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous endpoint the print-agent installer calls to redeem a pairing code for
|
||||
/// a long-lived token. The token is returned exactly once; only its hash is stored.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("api/print-agent")]
|
||||
public class PrintAgentPairingController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public PrintAgentPairingController(AppDbContext db) => _db = db;
|
||||
|
||||
[HttpPost("claim")]
|
||||
public async Task<IActionResult> Claim([FromBody] ClaimAgentRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Code))
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("CODE_REQUIRED", "Pairing code is required.")));
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var agent = await _db.PrintAgents
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(a =>
|
||||
a.PairingCode == request.Code &&
|
||||
a.TokenHash == null &&
|
||||
!a.Revoked &&
|
||||
a.DeletedAt == null &&
|
||||
a.PairingCodeExpiresAt > now, ct);
|
||||
|
||||
if (agent is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("INVALID_OR_EXPIRED_CODE", "This pairing code is invalid or has expired.")));
|
||||
|
||||
var token = NewToken();
|
||||
agent.TokenHash = PrintAgentHub.HashToken(token);
|
||||
agent.PairingCode = null;
|
||||
agent.PairingCodeExpiresAt = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.Name)) agent.Name = request.Name!.Trim();
|
||||
else if (!string.IsNullOrWhiteSpace(request.MachineName)) agent.Name = request.MachineName!.Trim();
|
||||
agent.LastSeenAt = now;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new ApiResponse<ClaimAgentResponse>(
|
||||
true, new ClaimAgentResponse(agent.Id, token, agent.CafeId, agent.Name)));
|
||||
}
|
||||
|
||||
private static string NewToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Hubs;
|
||||
using Meezi.API.Models.Printing;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
/// <summary>Manage the local print agents paired to a café (cloud → LAN bridge).</summary>
|
||||
[Route("api/cafes/{cafeId}/print-agents")]
|
||||
public class PrintAgentsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IPrintAgentRegistry _registry;
|
||||
private readonly IPrinterService _printer;
|
||||
|
||||
public PrintAgentsController(AppDbContext db, IPrintAgentRegistry registry, IPrinterService printer)
|
||||
{
|
||||
_db = db;
|
||||
_registry = registry;
|
||||
_printer = printer;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ViewPrintSettings) is { } permDenied) return permDenied;
|
||||
|
||||
var agents = await _db.PrintAgents
|
||||
.Where(a => a.CafeId == cafeId)
|
||||
.Include(a => a.Devices)
|
||||
.OrderBy(a => a.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var dtos = agents.Select(a => new PrintAgentDto(
|
||||
a.Id,
|
||||
a.Name,
|
||||
a.BranchId,
|
||||
_registry.IsOnline(a.Id),
|
||||
a.TokenHash is not null,
|
||||
a.LastSeenAt,
|
||||
a.CreatedAt,
|
||||
a.Devices
|
||||
.OrderBy(d => d.DisplayName)
|
||||
.Select(d => new PrintAgentDeviceDto(d.Id, d.SystemName, d.DisplayName, d.Kind, d.LastSeenAt))
|
||||
.ToList()
|
||||
)).ToList();
|
||||
|
||||
return Ok(new ApiResponse<IReadOnlyList<PrintAgentDto>>(true, dtos));
|
||||
}
|
||||
|
||||
/// <summary>Create a pending agent and a short one-time code the installer enters to pair.</summary>
|
||||
[HttpPost("pairing-code")]
|
||||
public async Task<IActionResult> CreatePairingCode(
|
||||
string cafeId,
|
||||
[FromBody] CreatePairingCodeRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||
|
||||
var code = await GenerateUniqueCodeAsync(ct);
|
||||
var agent = new PrintAgent
|
||||
{
|
||||
CafeId = cafeId,
|
||||
BranchId = string.IsNullOrWhiteSpace(request.BranchId) ? null : request.BranchId,
|
||||
Name = string.IsNullOrWhiteSpace(request.Name) ? "پرینتسرور" : request.Name!.Trim(),
|
||||
PairingCode = code,
|
||||
PairingCodeExpiresAt = DateTime.UtcNow.AddMinutes(15),
|
||||
};
|
||||
_db.PrintAgents.Add(agent);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new ApiResponse<PairingCodeResponse>(
|
||||
true, new PairingCodeResponse(agent.Id, code, agent.PairingCodeExpiresAt!.Value)));
|
||||
}
|
||||
|
||||
/// <summary>Unpair/revoke an agent — it can no longer connect or print.</summary>
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Revoke(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||
|
||||
var agent = await _db.PrintAgents.FirstOrDefaultAsync(a => a.Id == id && a.CafeId == cafeId, ct);
|
||||
if (agent is null)
|
||||
return NotFound(new ApiResponse<object>(false, null, new ApiError("AGENT_NOT_FOUND", "Print agent not found.")));
|
||||
|
||||
agent.Revoked = true;
|
||||
agent.TokenHash = null;
|
||||
agent.PairingCode = null;
|
||||
agent.DeletedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new ApiResponse<object>(true, null));
|
||||
}
|
||||
|
||||
/// <summary>Send a test page to a discovered printer through its agent.</summary>
|
||||
[HttpPost("devices/{deviceId}/test")]
|
||||
public async Task<IActionResult> TestDevice(string cafeId, string deviceId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||
|
||||
var result = await _printer.TestPrintDeviceAsync(cafeId, deviceId, ct);
|
||||
return result.Success
|
||||
? Ok(new ApiResponse<object>(true, null))
|
||||
: BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError(result.ErrorCode ?? "PRINT_FAILED", result.ErrorDetail ?? "Test print failed.")));
|
||||
}
|
||||
|
||||
/// <summary>Ask the café's online agents to scan their LAN for devices (network
|
||||
/// printers on :9100, card terminals on :8088) so the owner can pick instead of
|
||||
/// typing an IP. Merges results across agents.</summary>
|
||||
[HttpPost("scan")]
|
||||
public async Task<IActionResult> Scan(
|
||||
string cafeId,
|
||||
[FromBody] ScanRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||
|
||||
var online = _registry.OnlineAgentIdsForCafe(cafeId);
|
||||
if (online.Count == 0)
|
||||
return BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError("AGENT_OFFLINE", "No print agent is online to scan the network.")));
|
||||
|
||||
var ports = string.IsNullOrWhiteSpace(request.Ports) ? "9100,8088" : request.Ports!.Trim();
|
||||
var merged = new Dictionary<string, ScannedDeviceDto>();
|
||||
foreach (var agentId in online)
|
||||
{
|
||||
foreach (var d in await _registry.ScanAsync(agentId, ports, ct))
|
||||
merged[$"{d.Ip}:{d.Port}"] = new ScannedDeviceDto(d.Ip, d.Port, d.Kind);
|
||||
}
|
||||
|
||||
var dtos = merged.Values.OrderBy(d => d.Ip).ThenBy(d => d.Port).ToList();
|
||||
return Ok(new ApiResponse<IReadOnlyList<ScannedDeviceDto>>(true, dtos));
|
||||
}
|
||||
|
||||
private async Task<string> GenerateUniqueCodeAsync(CancellationToken ct)
|
||||
{
|
||||
for (var attempt = 0; attempt < 8; attempt++)
|
||||
{
|
||||
var code = RandomNumberGenerator.GetInt32(100_000, 1_000_000).ToString();
|
||||
var now = DateTime.UtcNow;
|
||||
var clash = await _db.PrintAgents.IgnoreQueryFilters().AnyAsync(
|
||||
a => a.PairingCode == code && a.PairingCodeExpiresAt > now && a.TokenHash == null, ct);
|
||||
if (!clash) return code;
|
||||
}
|
||||
// Extremely unlikely; fall back to a longer code.
|
||||
return RandomNumberGenerator.GetInt32(100_000, 1_000_000).ToString() +
|
||||
RandomNumberGenerator.GetInt32(10, 100).ToString();
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using Meezi.API.Services;
|
||||
using Meezi.API.Services.Delivery;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.API.Services.Payments;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure;
|
||||
using Serilog;
|
||||
@@ -94,6 +95,14 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IDemoSeedService, DemoSeedService>();
|
||||
services.AddScoped<ReceiptBuilder>();
|
||||
services.AddScoped<IPrinterService, NetworkPrinterService>();
|
||||
services.AddSingleton<IPrintAgentRegistry, PrintAgentRegistry>();
|
||||
services.Configure<FlatPayOptions>(configuration.GetSection(FlatPayOptions.SectionName));
|
||||
services.AddHttpClient<IFlatPayService, FlatPayService>((sp, c) =>
|
||||
{
|
||||
var baseUrl = configuration["FlatPay:BaseUrl"];
|
||||
c.BaseAddress = new Uri(string.IsNullOrWhiteSpace(baseUrl) ? "https://pay.flatrender.ir" : baseUrl);
|
||||
c.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
services.AddHttpClient(nameof(PosDeviceService));
|
||||
services.AddScoped<IPosDeviceService, PosDeviceService>();
|
||||
services.AddScoped<SubscriptionRenewalReminderJob>();
|
||||
@@ -224,6 +233,7 @@ public static class ServiceCollectionExtensions
|
||||
app.MapControllers();
|
||||
app.MapHub<KdsHub>("/hubs/kds");
|
||||
app.MapHub<GuestOrderHub>("/hubs/guest-order");
|
||||
app.MapHub<PrintAgentHub>("/hubs/print-agent");
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
|
||||
|
||||
if (!app.Configuration.GetValue<bool>("Testing:Enabled"))
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Local print agents connect here (outbound from the café PC), authenticated by
|
||||
/// their token in the <c>access_token</c> query param — agents are not users, so
|
||||
/// the hub self-authenticates rather than relying on the user JWT pipeline.
|
||||
/// They report the printers they can see and receive print jobs to relay locally.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
public class PrintAgentHub : Hub
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IPrintAgentRegistry _registry;
|
||||
private readonly ILogger<PrintAgentHub> _logger;
|
||||
|
||||
public PrintAgentHub(AppDbContext db, IPrintAgentRegistry registry, ILogger<PrintAgentHub> logger)
|
||||
{
|
||||
_db = db;
|
||||
_registry = registry;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>SHA-256 (hex) of an agent token — what we persist and compare against.</summary>
|
||||
public static string HashToken(string token) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
var token = Context.GetHttpContext()?.Request.Query["access_token"].ToString();
|
||||
if (string.IsNullOrEmpty(token)) { Context.Abort(); return; }
|
||||
|
||||
var hash = HashToken(token);
|
||||
var agent = await _db.PrintAgents
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(a => a.TokenHash == hash && !a.Revoked && a.DeletedAt == null);
|
||||
if (agent is null) { Context.Abort(); return; }
|
||||
|
||||
_registry.Register(Context.ConnectionId, agent.Id, agent.CafeId);
|
||||
agent.LastSeenAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
_registry.Unregister(Context.ConnectionId);
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
public record ReportedPrinter(string SystemName, string DisplayName, string? Kind);
|
||||
|
||||
/// <summary>Agent → cloud: the current set of printers it can reach. Upserts devices.</summary>
|
||||
public async Task ReportPrinters(IReadOnlyList<ReportedPrinter> printers)
|
||||
{
|
||||
if (_registry.Resolve(Context.ConnectionId) is not { } ctx) return;
|
||||
|
||||
var existing = await _db.PrintDevices.IgnoreQueryFilters()
|
||||
.Where(d => d.AgentId == ctx.AgentId)
|
||||
.ToListAsync();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var p in printers ?? [])
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(p.SystemName)) continue;
|
||||
var match = existing.FirstOrDefault(d => d.SystemName == p.SystemName);
|
||||
if (match is null)
|
||||
{
|
||||
_db.PrintDevices.Add(new PrintDevice
|
||||
{
|
||||
CafeId = ctx.CafeId,
|
||||
AgentId = ctx.AgentId,
|
||||
SystemName = p.SystemName,
|
||||
DisplayName = string.IsNullOrWhiteSpace(p.DisplayName) ? p.SystemName : p.DisplayName,
|
||||
Kind = string.IsNullOrWhiteSpace(p.Kind) ? "other" : p.Kind!,
|
||||
LastSeenAt = now,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
match.DisplayName = string.IsNullOrWhiteSpace(p.DisplayName) ? match.DisplayName : p.DisplayName;
|
||||
if (!string.IsNullOrWhiteSpace(p.Kind)) match.Kind = p.Kind!;
|
||||
match.LastSeenAt = now;
|
||||
match.DeletedAt = null; // a printer that came back is no longer "gone"
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>Agent → cloud: acknowledgement of a dispatched print job.</summary>
|
||||
public void JobResult(string jobId, bool success, string? error) =>
|
||||
_registry.CompleteJob(jobId, success, error);
|
||||
|
||||
/// <summary>Agent → cloud: result of a relayed card-terminal payment.</summary>
|
||||
public void PaymentResult(string requestId, bool success, string? error) =>
|
||||
_registry.CompleteJob(requestId, success, error);
|
||||
|
||||
/// <summary>Agent → cloud: hosts found by a LAN scan (network printers, card terminals).</summary>
|
||||
public void ReportScan(string requestId, IReadOnlyList<DiscoveredDevice> devices) =>
|
||||
_registry.CompleteScan(requestId, devices ?? []);
|
||||
|
||||
/// <summary>Agent → cloud: keep-alive so the dashboard can show an accurate "last seen".</summary>
|
||||
public async Task Heartbeat()
|
||||
{
|
||||
if (_registry.Resolve(Context.ConnectionId) is not { } ctx) return;
|
||||
var agent = await _db.PrintAgents.IgnoreQueryFilters().FirstOrDefaultAsync(a => a.Id == ctx.AgentId);
|
||||
if (agent is null) return;
|
||||
agent.LastSeenAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,21 @@ public record KitchenStationDto(
|
||||
string? PrinterIp,
|
||||
int PrinterPort,
|
||||
int SortOrder,
|
||||
int CategoryCount);
|
||||
int CategoryCount,
|
||||
string? PrintDeviceId);
|
||||
|
||||
public record CreateKitchenStationRequest(
|
||||
string Name,
|
||||
string? BranchId,
|
||||
string? PrinterIp,
|
||||
int PrinterPort = 9100,
|
||||
int SortOrder = 0);
|
||||
int SortOrder = 0,
|
||||
string? PrintDeviceId = null);
|
||||
|
||||
public record UpdateKitchenStationRequest(
|
||||
string? Name,
|
||||
string? BranchId,
|
||||
string? PrinterIp,
|
||||
int? PrinterPort,
|
||||
int? SortOrder);
|
||||
int? SortOrder,
|
||||
string? PrintDeviceId);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Meezi.API.Models.Payments;
|
||||
|
||||
/// <summary>Body for POST /api/payment/request. ProductId is a "Tier:Months" bundle, e.g. "Pro:12".</summary>
|
||||
public record PaymentRequestDto(string ProductId);
|
||||
|
||||
public record PaymentRequestResponse(string Url, string PaymentId);
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Meezi.API.Models.Printing;
|
||||
|
||||
public record PrintAgentDeviceDto(
|
||||
string Id,
|
||||
string SystemName,
|
||||
string DisplayName,
|
||||
string Kind,
|
||||
DateTime LastSeenAt);
|
||||
|
||||
public record PrintAgentDto(
|
||||
string Id,
|
||||
string Name,
|
||||
string? BranchId,
|
||||
bool Online,
|
||||
bool Paired,
|
||||
DateTime? LastSeenAt,
|
||||
DateTime CreatedAt,
|
||||
IReadOnlyList<PrintAgentDeviceDto> Devices);
|
||||
|
||||
public record CreatePairingCodeRequest(string? Name, string? BranchId);
|
||||
|
||||
public record PairingCodeResponse(string AgentId, string Code, DateTime ExpiresAt);
|
||||
|
||||
public record ClaimAgentRequest(string Code, string? Name, string? MachineName);
|
||||
|
||||
public record ClaimAgentResponse(string AgentId, string Token, string CafeId, string AgentName);
|
||||
|
||||
/// <summary>Ask online agents to scan the LAN for the given comma-separated TCP ports.</summary>
|
||||
public record ScanRequest(string? Ports);
|
||||
|
||||
public record ScannedDeviceDto(string Ip, int Port, string Kind);
|
||||
@@ -12,7 +12,9 @@ public record BranchPrintSettingsDto(
|
||||
string? ReceiptFooter,
|
||||
string? WifiPassword,
|
||||
string? PosDeviceIp,
|
||||
int? PosDevicePort);
|
||||
int? PosDevicePort,
|
||||
string? ReceiptPrintDeviceId,
|
||||
string? KitchenPrintDeviceId);
|
||||
|
||||
public record PatchBranchPrintSettingsRequest(
|
||||
string? ReceiptPrinterIp,
|
||||
@@ -25,7 +27,9 @@ public record PatchBranchPrintSettingsRequest(
|
||||
string? ReceiptFooter,
|
||||
string? WifiPassword,
|
||||
string? PosDeviceIp,
|
||||
int? PosDevicePort);
|
||||
int? PosDevicePort,
|
||||
string? ReceiptPrintDeviceId,
|
||||
string? KitchenPrintDeviceId);
|
||||
|
||||
public record PosPaymentRequest(string OrderId, decimal Amount);
|
||||
|
||||
|
||||
@@ -40,6 +40,21 @@ public interface IBillingService
|
||||
string cafeId,
|
||||
string paymentId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Price a plan+months bundle and create a Pending FlatPay SubscriptionPayment
|
||||
/// (the "order"); the returned id is passed to the broker as client_ref / metadata.payment_id.</summary>
|
||||
Task<(string? PaymentId, decimal AmountToman, string? ErrorCode, string? Message)> CreateFlatPayOrderAsync(
|
||||
string cafeId,
|
||||
PlanTier tier,
|
||||
int months,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Grant a FlatPay order after the broker reports it Paid: activate the plan using
|
||||
/// the same coverage/queueing logic as the other providers. Idempotent.</summary>
|
||||
Task<bool> CompleteFlatPayAsync(
|
||||
string paymentId,
|
||||
string? refId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class BillingService : IBillingService
|
||||
@@ -217,6 +232,16 @@ public class BillingService : IBillingService
|
||||
|
||||
payment.RefId = verify.RefId;
|
||||
|
||||
await ActivatePaymentAsync(payment, cancellationToken);
|
||||
|
||||
return new BillingVerifyResult(true, successUrl);
|
||||
}
|
||||
|
||||
/// <summary>Apply a paid SubscriptionPayment: book it after the current coverage (queued) or
|
||||
/// activate it now, update the cafe plan, persist, and send the confirmation SMS. Shared by all
|
||||
/// providers (gateway callbacks and the FlatPay webhook).</summary>
|
||||
private async Task ActivatePaymentAsync(SubscriptionPayment payment, CancellationToken cancellationToken)
|
||||
{
|
||||
var cafe = payment.Cafe;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
@@ -244,8 +269,75 @@ public class BillingService : IBillingService
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await TrySendConfirmationSmsAsync(cafe, payment, queued, cancellationToken);
|
||||
}
|
||||
|
||||
return new BillingVerifyResult(true, successUrl);
|
||||
public async Task<(string? PaymentId, decimal AmountToman, string? ErrorCode, string? Message)> CreateFlatPayOrderAsync(
|
||||
string cafeId,
|
||||
PlanTier tier,
|
||||
int months,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (months is < 1 or > 36)
|
||||
return (null, 0m, "INVALID_MONTHS", "Months must be between 1 and 36.");
|
||||
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null)
|
||||
return (null, 0m, "NOT_FOUND", "Cafe not found.");
|
||||
|
||||
if (!await _platformCatalog.IsBillableOnlineAsync(tier, cancellationToken))
|
||||
return (null, 0m, "NOT_BILLABLE", "This plan requires contacting sales.");
|
||||
|
||||
var monthly = await _platformCatalog.GetMonthlyPriceTomanAsync(tier, cancellationToken);
|
||||
if (monthly <= 0)
|
||||
return (null, 0m, "NOT_BILLABLE", "This plan has no online price.");
|
||||
|
||||
var amountToman = monthly * months;
|
||||
var payment = new SubscriptionPayment
|
||||
{
|
||||
CafeId = cafeId,
|
||||
PlanTier = tier,
|
||||
Months = months,
|
||||
AmountToman = amountToman,
|
||||
AmountRials = PlanPricing.ToRials(amountToman),
|
||||
Provider = PaymentProvider.FlatPay,
|
||||
Status = SubscriptionPaymentStatus.Pending,
|
||||
};
|
||||
|
||||
_db.SubscriptionPayments.Add(payment);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (payment.Id, amountToman, null, null);
|
||||
}
|
||||
|
||||
public async Task<bool> CompleteFlatPayAsync(
|
||||
string paymentId,
|
||||
string? refId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(paymentId))
|
||||
return false;
|
||||
|
||||
var payment = await _db.SubscriptionPayments
|
||||
.Include(p => p.Cafe)
|
||||
.FirstOrDefaultAsync(
|
||||
p => p.Id == paymentId && p.Provider == PaymentProvider.FlatPay,
|
||||
cancellationToken);
|
||||
|
||||
if (payment is null)
|
||||
{
|
||||
_logger.LogWarning("FlatPay grant: no pending order {PaymentId}", paymentId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Already granted (webhook redelivery / double-process) → idempotent no-op.
|
||||
if (payment.Status is SubscriptionPaymentStatus.Completed or SubscriptionPaymentStatus.Scheduled)
|
||||
return true;
|
||||
|
||||
payment.RefId = refId;
|
||||
await ActivatePaymentAsync(payment, cancellationToken);
|
||||
_logger.LogInformation("FlatPay grant applied: payment {PaymentId} → {Tier} x{Months}m",
|
||||
payment.Id, payment.PlanTier, payment.Months);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>End of the cafe's current paid coverage: the later of its active plan expiry
|
||||
|
||||
@@ -33,12 +33,13 @@ public class KitchenStationService : IKitchenStationService
|
||||
s.PrinterIp,
|
||||
s.PrinterPort,
|
||||
s.SortOrder,
|
||||
s.PrintDeviceId,
|
||||
CategoryCount = s.Categories.Count(c => c.DeletedAt == null)
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return stations.Select(s => new KitchenStationDto(
|
||||
s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount)).ToList();
|
||||
s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount, s.PrintDeviceId)).ToList();
|
||||
}
|
||||
|
||||
public async Task<KitchenStationDto?> CreateAsync(
|
||||
@@ -60,6 +61,7 @@ public class KitchenStationService : IKitchenStationService
|
||||
Name = request.Name.Trim(),
|
||||
PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim(),
|
||||
PrinterPort = request.PrinterPort > 0 ? request.PrinterPort : 9100,
|
||||
PrintDeviceId = string.IsNullOrWhiteSpace(request.PrintDeviceId) ? null : request.PrintDeviceId,
|
||||
SortOrder = request.SortOrder
|
||||
};
|
||||
|
||||
@@ -95,6 +97,8 @@ public class KitchenStationService : IKitchenStationService
|
||||
entity.PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim();
|
||||
if (request.PrinterPort.HasValue)
|
||||
entity.PrinterPort = request.PrinterPort.Value > 0 ? request.PrinterPort.Value : 9100;
|
||||
if (request.PrintDeviceId is not null)
|
||||
entity.PrintDeviceId = string.IsNullOrWhiteSpace(request.PrintDeviceId) ? null : request.PrintDeviceId;
|
||||
if (request.SortOrder.HasValue)
|
||||
entity.SortOrder = request.SortOrder.Value;
|
||||
|
||||
@@ -137,12 +141,13 @@ public class KitchenStationService : IKitchenStationService
|
||||
x.PrinterIp,
|
||||
x.PrinterPort,
|
||||
x.SortOrder,
|
||||
x.PrintDeviceId,
|
||||
CategoryCount = x.Categories.Count(c => c.DeletedAt == null)
|
||||
})
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
return s is null
|
||||
? null
|
||||
: new KitchenStationDto(s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount);
|
||||
: new KitchenStationDto(s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount, s.PrintDeviceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Meezi.API.Services.Payments;
|
||||
|
||||
public sealed class FlatPayOptions
|
||||
{
|
||||
public const string SectionName = "FlatPay";
|
||||
|
||||
public string ApiKey { get; set; } = "";
|
||||
public string Secret { get; set; } = "";
|
||||
public string BaseUrl { get; set; } = "https://pay.flatrender.ir";
|
||||
public string ReturnUrl { get; set; } = "https://meezi.ir/payment/return";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client for the FlatRender Pay broker (a ZarinPal front). Requests are authenticated
|
||||
/// with <c>X-Api-Key</c> + <c>X-Signature</c> = hex(HMAC-SHA256(secret, raw JSON bytes));
|
||||
/// webhooks are verified the same way. The signature is computed over the EXACT bytes
|
||||
/// that are sent/received, so we serialize once and reuse the buffer.
|
||||
/// </summary>
|
||||
public interface IFlatPayService
|
||||
{
|
||||
/// <summary>Create a payment at the broker and return its hosted payment URL (null on failure).
|
||||
/// <paramref name="clientRef"/> is echoed back and also embedded in metadata.payment_id.</summary>
|
||||
Task<string?> RequestAsync(
|
||||
string userId, string productId, long amountToman, string description, string clientRef,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Fixed-time compare hex(HMAC(secret, rawBytes)) against the webhook signature header.</summary>
|
||||
bool VerifyWebhook(byte[] rawBytes, string? signature);
|
||||
|
||||
/// <summary>Idempotency: true only the first time a given broker payment id is seen.</summary>
|
||||
bool TryMarkProcessed(string id);
|
||||
}
|
||||
|
||||
public sealed class FlatPayService : IFlatPayService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly FlatPayOptions _opts;
|
||||
private readonly ILogger<FlatPayService> _logger;
|
||||
|
||||
// Webhooks can be redelivered; remember the broker ids we've already granted.
|
||||
private readonly ConcurrentDictionary<string, byte> _seen = new();
|
||||
|
||||
public FlatPayService(HttpClient http, IOptions<FlatPayOptions> opts, ILogger<FlatPayService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_opts = opts.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string?> RequestAsync(
|
||||
string userId, string productId, long amountToman, string description, string clientRef,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var body = new PayRequestBody(
|
||||
amountToman,
|
||||
"IRT",
|
||||
description,
|
||||
clientRef,
|
||||
_opts.ReturnUrl,
|
||||
new PayMetadata(userId, productId, clientRef));
|
||||
|
||||
// Serialize once: these exact bytes are both signed and sent.
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(body);
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "/v1/pay/request");
|
||||
req.Content = new ByteArrayContent(bytes);
|
||||
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
req.Headers.TryAddWithoutValidation("X-Api-Key", _opts.ApiKey);
|
||||
req.Headers.TryAddWithoutValidation("X-Signature", Sign(bytes));
|
||||
|
||||
try
|
||||
{
|
||||
using var resp = await _http.SendAsync(req, ct);
|
||||
var respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("FlatPay /v1/pay/request failed {Status}: {Body}", (int)resp.StatusCode, respBody);
|
||||
return null;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(respBody);
|
||||
var url = ExtractPaymentUrl(doc.RootElement);
|
||||
if (string.IsNullOrEmpty(url))
|
||||
_logger.LogError("FlatPay request returned no payment_url: {Body}", respBody);
|
||||
return url;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "FlatPay request error");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool VerifyWebhook(byte[] rawBytes, string? signature)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(signature)) return false;
|
||||
var expected = Sign(rawBytes);
|
||||
var provided = signature.Trim().ToLowerInvariant();
|
||||
|
||||
// Compare the ascii hex digests in fixed time.
|
||||
var a = Encoding.ASCII.GetBytes(expected);
|
||||
var b = Encoding.ASCII.GetBytes(provided);
|
||||
return a.Length == b.Length && CryptographicOperations.FixedTimeEquals(a, b);
|
||||
}
|
||||
|
||||
public bool TryMarkProcessed(string id) =>
|
||||
!string.IsNullOrEmpty(id) && _seen.TryAdd(id, 0);
|
||||
|
||||
private string Sign(byte[] body)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_opts.Secret));
|
||||
return Convert.ToHexString(hmac.ComputeHash(body)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? ExtractPaymentUrl(JsonElement root)
|
||||
{
|
||||
if (TryGetString(root, "payment_url") is { } direct) return direct;
|
||||
// Some broker responses nest the result under "data".
|
||||
if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
|
||||
return TryGetString(data, "payment_url");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TryGetString(JsonElement el, string name) =>
|
||||
el.ValueKind == JsonValueKind.Object
|
||||
&& el.TryGetProperty(name, out var v)
|
||||
&& v.ValueKind == JsonValueKind.String
|
||||
? v.GetString()
|
||||
: null;
|
||||
|
||||
private sealed record PayRequestBody(
|
||||
[property: JsonPropertyName("amount")] long Amount,
|
||||
[property: JsonPropertyName("currency")] string Currency,
|
||||
[property: JsonPropertyName("description")] string Description,
|
||||
[property: JsonPropertyName("client_ref")] string ClientRef,
|
||||
[property: JsonPropertyName("return_url")] string ReturnUrl,
|
||||
[property: JsonPropertyName("metadata")] PayMetadata Metadata);
|
||||
|
||||
private sealed record PayMetadata(
|
||||
[property: JsonPropertyName("user_id")] string UserId,
|
||||
[property: JsonPropertyName("product_id")] string ProductId,
|
||||
[property: JsonPropertyName("payment_id")] string PaymentId);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Meezi.API.Models.Printing;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -29,15 +30,18 @@ public class PosDeviceService : IPosDeviceService
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IPrintAgentRegistry _agents;
|
||||
private readonly ILogger<PosDeviceService> _logger;
|
||||
|
||||
public PosDeviceService(
|
||||
AppDbContext db,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IPrintAgentRegistry agents,
|
||||
ILogger<PosDeviceService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_agents = agents;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -71,14 +75,31 @@ public class PosDeviceService : IPosDeviceService
|
||||
if (order is null)
|
||||
return PosDeviceResult.Fail("ORDER_NOT_FOUND");
|
||||
|
||||
var amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero);
|
||||
var ip = branch.PosDeviceIp!.Trim();
|
||||
|
||||
// Prefer relaying through a local print agent on the café LAN — the cloud
|
||||
// can't reach the terminal's private IP directly (same reason the agent
|
||||
// exists for printers). Fall back to a direct call only on-prem / when no
|
||||
// agent is connected.
|
||||
var agentId = await ResolveOnlineAgentAsync(cafeId, branchId, ct);
|
||||
if (agentId is not null)
|
||||
{
|
||||
var outcome = await _agents.SendPaymentAsync(agentId, ip, port, amount, request.OrderId, ct);
|
||||
if (outcome.Success)
|
||||
return PosDeviceResult.Ok();
|
||||
_logger.LogWarning("Agent-relayed POS payment failed ({Agent}): {Error}", agentId, outcome.Error);
|
||||
return PosDeviceResult.Fail(MapAgentError(outcome.Error), outcome.Error);
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero),
|
||||
amount,
|
||||
orderId = request.OrderId,
|
||||
branchId,
|
||||
};
|
||||
|
||||
var url = $"http://{branch.PosDeviceIp!.Trim()}:{port}/pay";
|
||||
var url = $"http://{ip}:{port}/pay";
|
||||
|
||||
try
|
||||
{
|
||||
@@ -117,4 +138,31 @@ public class PosDeviceService : IPosDeviceService
|
||||
return PosDeviceResult.Fail("POS_DEVICE_CONNECTION_FAILED", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The online agent best placed to reach this branch's terminal — one
|
||||
/// bound to the branch if present, else any online agent of the café.</summary>
|
||||
private async Task<string?> ResolveOnlineAgentAsync(string cafeId, string branchId, CancellationToken ct)
|
||||
{
|
||||
var online = _agents.OnlineAgentIdsForCafe(cafeId);
|
||||
if (online.Count == 0) return null;
|
||||
|
||||
var agents = await _db.PrintAgents
|
||||
.AsNoTracking()
|
||||
.Where(a => a.CafeId == cafeId && !a.Revoked && a.DeletedAt == null)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return agents.FirstOrDefault(a => a.BranchId == branchId && online.Contains(a.Id))?.Id
|
||||
?? agents.FirstOrDefault(a => online.Contains(a.Id))?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Normalize an agent-relay error string back to a POS_DEVICE_* code.</summary>
|
||||
private static string MapAgentError(string? error) => error switch
|
||||
{
|
||||
null or "" => "POS_DEVICE_FAILED",
|
||||
var e when e.Contains("TIMEOUT", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_TIMEOUT",
|
||||
var e when e.StartsWith("POS_DEVICE_", StringComparison.Ordinal) => e.Split(':')[0],
|
||||
var e when e.Contains("REJECT", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_REJECTED",
|
||||
var e when e.Contains("OFFLINE", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_CONNECTION_FAILED",
|
||||
_ => "POS_DEVICE_CONNECTION_FAILED",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ public interface IPrinterService
|
||||
string? stationId = null,
|
||||
CancellationToken ct = default);
|
||||
Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default);
|
||||
Task<PrintResult> TestPrintDeviceAsync(string cafeId, string deviceId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class NetworkPrinterService : IPrinterService
|
||||
@@ -29,17 +30,20 @@ public class NetworkPrinterService : IPrinterService
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IOrderService _orders;
|
||||
private readonly ReceiptBuilder _receiptBuilder;
|
||||
private readonly IPrintAgentRegistry _agents;
|
||||
private readonly ILogger<NetworkPrinterService> _logger;
|
||||
|
||||
public NetworkPrinterService(
|
||||
AppDbContext db,
|
||||
IOrderService orders,
|
||||
ReceiptBuilder receiptBuilder,
|
||||
IPrintAgentRegistry agents,
|
||||
ILogger<NetworkPrinterService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_orders = orders;
|
||||
_receiptBuilder = receiptBuilder;
|
||||
_agents = agents;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -49,13 +53,16 @@ public class NetworkPrinterService : IPrinterService
|
||||
if (ctx is null)
|
||||
return PrintResult.Fail("ORDER_NOT_FOUND");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ctx.Value.branch.ReceiptPrinterIp))
|
||||
var branch = ctx.Value.branch;
|
||||
if (string.IsNullOrWhiteSpace(branch.ReceiptPrintDeviceId) && string.IsNullOrWhiteSpace(branch.ReceiptPrinterIp))
|
||||
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
|
||||
|
||||
var bytes = _receiptBuilder.BuildReceipt(ctx.Value.printCtx);
|
||||
return await SendToPrinterAsync(
|
||||
ctx.Value.branch.ReceiptPrinterIp!,
|
||||
ctx.Value.branch.ReceiptPrinterPort ?? 9100,
|
||||
return await DispatchAsync(
|
||||
cafeId,
|
||||
branch.ReceiptPrintDeviceId,
|
||||
branch.ReceiptPrinterIp,
|
||||
branch.ReceiptPrinterPort ?? 9100,
|
||||
bytes,
|
||||
ct);
|
||||
}
|
||||
@@ -122,18 +129,21 @@ public class NetworkPrinterService : IPrinterService
|
||||
? null
|
||||
: stations.FirstOrDefault(s => s.Id == group.Key);
|
||||
|
||||
string? deviceId;
|
||||
string? ip;
|
||||
int port;
|
||||
string? stationLabel = null;
|
||||
|
||||
if (station is not null && !string.IsNullOrWhiteSpace(station.PrinterIp))
|
||||
if (station is not null && (!string.IsNullOrWhiteSpace(station.PrintDeviceId) || !string.IsNullOrWhiteSpace(station.PrinterIp)))
|
||||
{
|
||||
deviceId = station.PrintDeviceId;
|
||||
ip = station.PrinterIp;
|
||||
port = station.PrinterPort;
|
||||
stationLabel = station.Name;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrinterIp))
|
||||
else if (!string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrintDeviceId) || !string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrinterIp))
|
||||
{
|
||||
deviceId = ctx.Value.branch.KitchenPrintDeviceId;
|
||||
ip = ctx.Value.branch.KitchenPrinterIp;
|
||||
port = ctx.Value.branch.KitchenPrinterPort ?? 9100;
|
||||
}
|
||||
@@ -147,7 +157,7 @@ public class NetworkPrinterService : IPrinterService
|
||||
var bytes = _receiptBuilder.BuildKitchenTicket(
|
||||
ctx.Value.printCtx with { StationName = stationLabel },
|
||||
itemsOnly);
|
||||
var result = await SendToPrinterAsync(ip!, port, bytes, ct);
|
||||
var result = await DispatchAsync(cafeId, deviceId, ip, port, bytes, ct);
|
||||
if (result.Success)
|
||||
anyPrinted = true;
|
||||
else
|
||||
@@ -166,6 +176,66 @@ public class NetworkPrinterService : IPrinterService
|
||||
return await SendToPrinterAsync(printerIp.Trim(), port, bytes, ct);
|
||||
}
|
||||
|
||||
public async Task<PrintResult> TestPrintDeviceAsync(string cafeId, string deviceId, CancellationToken ct = default)
|
||||
{
|
||||
var device = await _db.PrintDevices.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.Id == deviceId && d.CafeId == cafeId, ct);
|
||||
if (device is null)
|
||||
return PrintResult.Fail("DEVICE_NOT_FOUND");
|
||||
if (!_agents.IsOnline(device.AgentId))
|
||||
return PrintResult.Fail("AGENT_OFFLINE");
|
||||
|
||||
var bytes = _receiptBuilder.BuildTestPage();
|
||||
var outcome = await _agents.SendJobAsync(device.AgentId, new PrintJobRequest(device.SystemName, bytes), ct);
|
||||
return outcome.Success ? PrintResult.Ok() : PrintResult.Fail("AGENT_PRINT_FAILED", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send bytes to a printer, preferring a local print agent when one is mapped and
|
||||
/// online (the only way to reach a LAN/USB printer from the cloud); otherwise fall
|
||||
/// back to a direct TCP connection (on-prem deployments / reachable printers).
|
||||
/// </summary>
|
||||
private async Task<PrintResult> DispatchAsync(
|
||||
string cafeId,
|
||||
string? deviceId,
|
||||
string? ip,
|
||||
int port,
|
||||
byte[] bytes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
var device = await _db.PrintDevices.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.Id == deviceId && d.CafeId == cafeId, ct);
|
||||
|
||||
if (device is not null && _agents.IsOnline(device.AgentId))
|
||||
{
|
||||
var outcome = await _agents.SendJobAsync(
|
||||
device.AgentId, new PrintJobRequest(device.SystemName, bytes), ct);
|
||||
if (outcome.Success)
|
||||
{
|
||||
_logger.LogInformation("Printed {Bytes} bytes via agent {Agent} → {Printer}",
|
||||
bytes.Length, device.AgentId, device.SystemName);
|
||||
return PrintResult.Ok();
|
||||
}
|
||||
_logger.LogWarning("Agent print failed ({Printer}): {Error}", device.SystemName, outcome.Error);
|
||||
// Only surface the failure if there's no IP to fall back to.
|
||||
if (string.IsNullOrWhiteSpace(ip))
|
||||
return PrintResult.Fail("AGENT_PRINT_FAILED", outcome.Error);
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(ip))
|
||||
{
|
||||
return PrintResult.Fail("AGENT_OFFLINE");
|
||||
}
|
||||
// Agent offline/missing but an IP is configured → fall through to TCP.
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ip))
|
||||
return await SendToPrinterAsync(ip!.Trim(), port, bytes, ct);
|
||||
|
||||
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
|
||||
}
|
||||
|
||||
private async Task<(Branch branch, ReceiptPrintContext printCtx)?> BuildContextAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Meezi.API.Hubs;
|
||||
|
||||
namespace Meezi.API.Services.Printing;
|
||||
|
||||
public record PrintJobRequest(string PrinterSystemName, byte[] Payload);
|
||||
public record PrintJobOutcome(bool Success, string? Error);
|
||||
|
||||
/// <summary>A host the agent found on the café LAN responding on a probed port
|
||||
/// (a network printer on :9100, a card terminal on :8088, …).</summary>
|
||||
public record DiscoveredDevice(string Ip, int Port, string Kind);
|
||||
|
||||
/// <summary>
|
||||
/// Tracks which print agents are currently connected (by SignalR connection) and
|
||||
/// dispatches print jobs to them, awaiting the agent's acknowledgement. In-memory:
|
||||
/// a dropped process simply means agents reconnect and re-register.
|
||||
/// </summary>
|
||||
public interface IPrintAgentRegistry
|
||||
{
|
||||
void Register(string connectionId, string agentId, string cafeId);
|
||||
void Unregister(string connectionId);
|
||||
(string AgentId, string CafeId)? Resolve(string connectionId);
|
||||
bool IsOnline(string agentId);
|
||||
IReadOnlySet<string> OnlineAgentIds();
|
||||
/// <summary>Online agents belonging to a café — used to pick a LAN bridge for a
|
||||
/// card-terminal payment or a network scan.</summary>
|
||||
IReadOnlySet<string> OnlineAgentIdsForCafe(string cafeId);
|
||||
Task<PrintJobOutcome> SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default);
|
||||
void CompleteJob(string jobId, bool success, string? error);
|
||||
/// <summary>Relay a card-terminal payment through the agent on the café LAN; it
|
||||
/// POSTs the amount to the terminal at ip:port and acks the approval result.</summary>
|
||||
Task<PrintJobOutcome> SendPaymentAsync(
|
||||
string agentId, string ip, int port, long amount, string orderId, CancellationToken ct = default);
|
||||
/// <summary>Ask the agent to scan its LAN for hosts answering on the given ports.</summary>
|
||||
Task<IReadOnlyList<DiscoveredDevice>> ScanAsync(
|
||||
string agentId, string ports, CancellationToken ct = default);
|
||||
void CompleteScan(string requestId, IReadOnlyList<DiscoveredDevice> devices);
|
||||
}
|
||||
|
||||
public class PrintAgentRegistry : IPrintAgentRegistry
|
||||
{
|
||||
private readonly IHubContext<PrintAgentHub> _hub;
|
||||
private readonly ConcurrentDictionary<string, (string AgentId, string CafeId)> _byConnection = new();
|
||||
private readonly ConcurrentDictionary<string, string> _agentConnection = new(); // agentId -> connectionId
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<PrintJobOutcome>> _pending = new();
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<IReadOnlyList<DiscoveredDevice>>> _pendingScans = new();
|
||||
|
||||
public PrintAgentRegistry(IHubContext<PrintAgentHub> hub) => _hub = hub;
|
||||
|
||||
public void Register(string connectionId, string agentId, string cafeId)
|
||||
{
|
||||
_byConnection[connectionId] = (agentId, cafeId);
|
||||
_agentConnection[agentId] = connectionId;
|
||||
}
|
||||
|
||||
public void Unregister(string connectionId)
|
||||
{
|
||||
if (!_byConnection.TryRemove(connectionId, out var info)) return;
|
||||
// Only drop the agent→connection mapping if it still points at this socket
|
||||
// (a fast reconnect may already have replaced it with a newer one).
|
||||
if (_agentConnection.TryGetValue(info.AgentId, out var current) && current == connectionId)
|
||||
_agentConnection.TryRemove(info.AgentId, out _);
|
||||
}
|
||||
|
||||
public (string AgentId, string CafeId)? Resolve(string connectionId) =>
|
||||
_byConnection.TryGetValue(connectionId, out var info) ? info : null;
|
||||
|
||||
public bool IsOnline(string agentId) => _agentConnection.ContainsKey(agentId);
|
||||
|
||||
public IReadOnlySet<string> OnlineAgentIds() => _agentConnection.Keys.ToHashSet();
|
||||
|
||||
public IReadOnlySet<string> OnlineAgentIdsForCafe(string cafeId) =>
|
||||
_byConnection.Values
|
||||
.Where(v => v.CafeId == cafeId)
|
||||
.Select(v => v.AgentId)
|
||||
.ToHashSet();
|
||||
|
||||
public async Task<PrintJobOutcome> SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default)
|
||||
{
|
||||
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
|
||||
return new PrintJobOutcome(false, "AGENT_OFFLINE");
|
||||
|
||||
var jobId = Guid.NewGuid().ToString("N");
|
||||
var tcs = new TaskCompletionSource<PrintJobOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[jobId] = tcs;
|
||||
try
|
||||
{
|
||||
await _hub.Clients.Client(connectionId).SendAsync(
|
||||
"PrintJob", jobId, job.PrinterSystemName, Convert.ToBase64String(job.Payload), ct);
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(20));
|
||||
using var reg = timeout.Token.Register(() => tcs.TrySetResult(new PrintJobOutcome(false, "TIMEOUT")));
|
||||
return await tcs.Task;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrintJobOutcome(false, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(jobId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void CompleteJob(string jobId, bool success, string? error)
|
||||
{
|
||||
if (_pending.TryGetValue(jobId, out var tcs))
|
||||
tcs.TrySetResult(new PrintJobOutcome(success, error));
|
||||
}
|
||||
|
||||
public async Task<PrintJobOutcome> SendPaymentAsync(
|
||||
string agentId, string ip, int port, long amount, string orderId, CancellationToken ct = default)
|
||||
{
|
||||
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
|
||||
return new PrintJobOutcome(false, "AGENT_OFFLINE");
|
||||
|
||||
var requestId = Guid.NewGuid().ToString("N");
|
||||
var tcs = new TaskCompletionSource<PrintJobOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[requestId] = tcs;
|
||||
try
|
||||
{
|
||||
await _hub.Clients.Client(connectionId).SendAsync(
|
||||
"PaymentRequest", requestId, ip, port, amount, orderId, ct);
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
// Card payment waits on the customer at the terminal — give it the same
|
||||
// headroom the direct path uses.
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(95));
|
||||
using var reg = timeout.Token.Register(() => tcs.TrySetResult(new PrintJobOutcome(false, "POS_DEVICE_TIMEOUT")));
|
||||
return await tcs.Task;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrintJobOutcome(false, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(requestId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DiscoveredDevice>> ScanAsync(
|
||||
string agentId, string ports, CancellationToken ct = default)
|
||||
{
|
||||
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
|
||||
return [];
|
||||
|
||||
var requestId = Guid.NewGuid().ToString("N");
|
||||
var tcs = new TaskCompletionSource<IReadOnlyList<DiscoveredDevice>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pendingScans[requestId] = tcs;
|
||||
try
|
||||
{
|
||||
await _hub.Clients.Client(connectionId).SendAsync("ScanNetwork", requestId, ports, ct);
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
using var reg = timeout.Token.Register(() => tcs.TrySetResult([]));
|
||||
return await tcs.Task;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingScans.TryRemove(requestId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void CompleteScan(string requestId, IReadOnlyList<DiscoveredDevice> devices)
|
||||
{
|
||||
if (_pendingScans.TryGetValue(requestId, out var tcs))
|
||||
tcs.TrySetResult(devices);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,12 @@
|
||||
"MerchantId": "",
|
||||
"Sandbox": true
|
||||
},
|
||||
"FlatPay": {
|
||||
"BaseUrl": "https://pay.flatrender.ir",
|
||||
"ReturnUrl": "https://meezi.ir/payment/return",
|
||||
"ApiKey": "",
|
||||
"Secret": ""
|
||||
},
|
||||
"Billing": {
|
||||
"DashboardBaseUrl": "http://localhost:3101"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,11 @@ public class Branch : TenantEntity
|
||||
public int? ReceiptPrinterPort { get; set; }
|
||||
public string? KitchenPrinterIp { get; set; }
|
||||
public int? KitchenPrinterPort { get; set; }
|
||||
|
||||
/// <summary>Optional <see cref="PrintDevice"/> to route through a local print agent
|
||||
/// (preferred over the raw IP when its agent is online). Cloud-hosted cafés use this.</summary>
|
||||
public string? ReceiptPrintDeviceId { get; set; }
|
||||
public string? KitchenPrintDeviceId { get; set; }
|
||||
public int PaperWidthMm { get; set; } = 80;
|
||||
public bool AutoCutEnabled { get; set; } = true;
|
||||
public string? ReceiptHeader { get; set; }
|
||||
|
||||
@@ -7,6 +7,11 @@ public class KitchenStation : TenantEntity
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? PrinterIp { get; set; }
|
||||
public int PrinterPort { get; set; } = 9100;
|
||||
|
||||
/// <summary>Optional <see cref="PrintDevice"/> routed through a local print agent
|
||||
/// (preferred over <see cref="PrinterIp"/> when its agent is online).</summary>
|
||||
public string? PrintDeviceId { get; set; }
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public Cafe Cafe { get; set; } = null!;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Meezi.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A local print bridge installed on a café PC. It connects outbound to the cloud
|
||||
/// over SignalR (authenticated by its token), reports the printers it can see, and
|
||||
/// relays print jobs to them — so the cloud can reach LAN/USB printers it could
|
||||
/// never connect to directly.
|
||||
/// </summary>
|
||||
public class PrintAgent : TenantEntity
|
||||
{
|
||||
public string? BranchId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Short one-time code shown in the dashboard; the agent exchanges it for a token.</summary>
|
||||
public string? PairingCode { get; set; }
|
||||
public DateTime? PairingCodeExpiresAt { get; set; }
|
||||
|
||||
/// <summary>SHA-256 (hex) of the long-lived agent token. Null until the agent is paired.</summary>
|
||||
public string? TokenHash { get; set; }
|
||||
|
||||
/// <summary>Last time the agent connected or sent a heartbeat (UTC).</summary>
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
|
||||
public bool Revoked { get; set; }
|
||||
|
||||
public Cafe Cafe { get; set; } = null!;
|
||||
public Branch? Branch { get; set; }
|
||||
public ICollection<PrintDevice> Devices { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Meezi.Core.Entities;
|
||||
|
||||
/// <summary>A printer discovered and reported by a <see cref="PrintAgent"/>.</summary>
|
||||
public class PrintDevice : TenantEntity
|
||||
{
|
||||
public string AgentId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Stable identifier the agent uses to print (OS printer name, or "ip:port").</summary>
|
||||
public string SystemName { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>"usb" | "network" | "other".</summary>
|
||||
public string Kind { get; set; } = "other";
|
||||
|
||||
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public PrintAgent Agent { get; set; } = null!;
|
||||
}
|
||||
@@ -4,7 +4,9 @@ public enum PaymentProvider
|
||||
{
|
||||
ZarinPal = 0,
|
||||
Tara = 1,
|
||||
SnappPay = 2
|
||||
SnappPay = 2,
|
||||
// Appended (stored as int) so existing rows keep their meaning — no migration needed.
|
||||
FlatPay = 3
|
||||
}
|
||||
|
||||
public static class PaymentProviderIds
|
||||
|
||||
@@ -54,6 +54,8 @@ public class AppDbContext : DbContext
|
||||
public DbSet<CafeReviewPhoto> CafeReviewPhotos => Set<CafeReviewPhoto>();
|
||||
public DbSet<ConsumerAccount> ConsumerAccounts => Set<ConsumerAccount>();
|
||||
public DbSet<KitchenStation> KitchenStations => Set<KitchenStation>();
|
||||
public DbSet<PrintAgent> PrintAgents => Set<PrintAgent>();
|
||||
public DbSet<PrintDevice> PrintDevices => Set<PrintDevice>();
|
||||
public DbSet<SubscriptionPayment> SubscriptionPayments => Set<SubscriptionPayment>();
|
||||
public DbSet<Ingredient> Ingredients => Set<Ingredient>();
|
||||
public DbSet<MenuItemIngredient> MenuItemIngredients => Set<MenuItemIngredient>();
|
||||
@@ -459,6 +461,32 @@ public class AppDbContext : DbContext
|
||||
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PrintAgent>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.Name).HasMaxLength(120).IsRequired();
|
||||
e.Property(x => x.PairingCode).HasMaxLength(16);
|
||||
e.Property(x => x.TokenHash).HasMaxLength(128);
|
||||
e.HasIndex(x => x.TokenHash);
|
||||
e.HasIndex(x => x.PairingCode);
|
||||
e.HasIndex(x => x.CafeId);
|
||||
e.HasOne(x => x.Cafe).WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasMany(x => x.Devices).WithOne(d => d.Agent).HasForeignKey(d => d.AgentId).OnDelete(DeleteBehavior.Cascade);
|
||||
// Café-wide agents (BranchId null) stay visible inside any branch scope.
|
||||
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId || x.BranchId == null));
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PrintDevice>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.SystemName).HasMaxLength(256).IsRequired();
|
||||
e.Property(x => x.DisplayName).HasMaxLength(256).IsRequired();
|
||||
e.Property(x => x.Kind).HasMaxLength(20);
|
||||
e.HasIndex(x => new { x.AgentId, x.SystemName }).IsUnique();
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SubscriptionPayment>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
|
||||
+3643
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPrintAgents : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PrintAgents",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
BranchId = table.Column<string>(type: "text", nullable: true),
|
||||
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
PairingCode = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: true),
|
||||
PairingCodeExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
TokenHash = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
LastSeenAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
Revoked = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CafeId = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PrintAgents", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PrintAgents_Branches_BranchId",
|
||||
column: x => x.BranchId,
|
||||
principalTable: "Branches",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_PrintAgents_Cafes_CafeId",
|
||||
column: x => x.CafeId,
|
||||
principalTable: "Cafes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PrintDevices",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
AgentId = table.Column<string>(type: "text", nullable: false),
|
||||
SystemName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
DisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Kind = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
LastSeenAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CafeId = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PrintDevices", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PrintDevices_PrintAgents_AgentId",
|
||||
column: x => x.AgentId,
|
||||
principalTable: "PrintAgents",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PrintAgents_BranchId",
|
||||
table: "PrintAgents",
|
||||
column: "BranchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PrintAgents_CafeId",
|
||||
table: "PrintAgents",
|
||||
column: "CafeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PrintAgents_PairingCode",
|
||||
table: "PrintAgents",
|
||||
column: "PairingCode");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PrintAgents_TokenHash",
|
||||
table: "PrintAgents",
|
||||
column: "TokenHash");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PrintDevices_AgentId_SystemName",
|
||||
table: "PrintDevices",
|
||||
columns: new[] { "AgentId", "SystemName" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PrintDevices");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PrintAgents");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3652
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPrintDeviceRouting : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PrintDeviceId",
|
||||
table: "KitchenStations",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "KitchenPrintDeviceId",
|
||||
table: "Branches",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ReceiptPrintDeviceId",
|
||||
table: "Branches",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PrintDeviceId",
|
||||
table: "KitchenStations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "KitchenPrintDeviceId",
|
||||
table: "Branches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReceiptPrintDeviceId",
|
||||
table: "Branches");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("KitchenPrintDeviceId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("KitchenPrinterIp")
|
||||
.HasMaxLength(45)
|
||||
.HasColumnType("character varying(45)");
|
||||
@@ -192,6 +195,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("ReceiptPrintDeviceId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ReceiptPrinterIp")
|
||||
.HasMaxLength(45)
|
||||
.HasColumnType("character varying(45)");
|
||||
@@ -1313,6 +1319,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("PrintDeviceId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PrinterIp")
|
||||
.HasMaxLength(45)
|
||||
.HasColumnType("character varying(45)");
|
||||
@@ -1935,6 +1944,104 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.ToTable("PlatformSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.PrintAgent", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BranchId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CafeId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("PairingCode")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTime?>("PairingCodeExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("Revoked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CafeId");
|
||||
|
||||
b.HasIndex("PairingCode");
|
||||
|
||||
b.HasIndex("TokenHash");
|
||||
|
||||
b.ToTable("PrintAgents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.PrintDevice", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AgentId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CafeId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SystemName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AgentId", "SystemName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PrintDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.PushDevice", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@@ -3150,6 +3257,35 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.Navigation("Order");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.PrintAgent", b =>
|
||||
{
|
||||
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
|
||||
.WithMany()
|
||||
.HasForeignKey("BranchId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
|
||||
.WithMany()
|
||||
.HasForeignKey("CafeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Cafe");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.PrintDevice", b =>
|
||||
{
|
||||
b.HasOne("Meezi.Core.Entities.PrintAgent", "Agent")
|
||||
.WithMany("Devices")
|
||||
.HasForeignKey("AgentId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Agent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.QueueTicket", b =>
|
||||
{
|
||||
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
|
||||
@@ -3473,6 +3609,11 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.Navigation("Payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.PrintAgent", b =>
|
||||
{
|
||||
b.Navigation("Devices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.Shift", b =>
|
||||
{
|
||||
b.Navigation("Transactions");
|
||||
|
||||
@@ -333,6 +333,11 @@
|
||||
"posDeviceSection": "جهاز نقطة البيع (بطاقة)",
|
||||
"posDeviceHint": "عند الدفع بالبطاقة، يُرسل المبلغ عبر HTTP (POST /pay) إلى الجهاز على الشبكة المحلية.",
|
||||
"posDeviceIp": "عنوان IP لجهاز نقطة البيع",
|
||||
"detect": "كشف تلقائي",
|
||||
"detecting": "جارٍ فحص الشبكة…",
|
||||
"detectNone": "لم يُعثر على أجهزة في الشبكة",
|
||||
"detectOffline": "يجب أن يكون خادم الطباعة متصلاً للكشف التلقائي",
|
||||
"detectHint": "يفحص خادم الطباعة شبكتك المحلية للعثور على الجهاز.",
|
||||
"testSent": "تم إرسال الاختبار إلى الطابعة.",
|
||||
"sent": "تم الإرسال إلى الطابعة.",
|
||||
"noStationItems": "لا توجد أصناف لهذه المحطة في هذا الطلب.",
|
||||
@@ -351,6 +356,24 @@
|
||||
"empty": "لا توجد محطات بعد. أضف «المطبخ» و«البار» لطباعة أصنافهما بشكل منفصل.",
|
||||
"deleteConfirm": "حذف المحطة «{name}»؟ ستعود فئاتها إلى طابعة المطبخ.",
|
||||
"saveError": "تعذّر حفظ المحطة."
|
||||
},
|
||||
"agents": {
|
||||
"title": "خوادم الطباعة (اكتشاف تلقائي)",
|
||||
"hint": "ثبّت وكيل طباعة Meezi على جهاز الكاشير لاكتشاف طابعات USB والشبكة تلقائياً والطباعة عبره.",
|
||||
"add": "إضافة خادم طباعة",
|
||||
"pairingTitle": "أدخل هذا الرمز في وكيل الطباعة على جهاز الكاشير:",
|
||||
"pairingSteps": "ثبّت وشغّل وكيل طباعة Meezi على الجهاز المتصل بالطابعات ثم أدخل هذا الرمز. صالح لمدة 15 دقيقة.",
|
||||
"empty": "لا يوجد خادم طباعة متصل بعد.",
|
||||
"online": "متصل",
|
||||
"offline": "غير متصل",
|
||||
"noDevices": "جارٍ اكتشاف الطابعات…",
|
||||
"test": "اختبار",
|
||||
"receiptVia": "طابعة الإيصال (عبر الخادم)",
|
||||
"kitchenVia": "طابعة المطبخ (عبر الخادم)",
|
||||
"viaServer": "الطابعة (عبر الخادم)",
|
||||
"useIpInstead": "— استخدام IP يدوي —",
|
||||
"revokeConfirm": "إزالة خادم الطباعة «{name}»؟ لن يتمكن من الطباعة بعد ذلك.",
|
||||
"codeError": "تعذّر إنشاء الرمز."
|
||||
}
|
||||
},
|
||||
"receipt": {
|
||||
|
||||
@@ -352,6 +352,11 @@
|
||||
"posDeviceSection": "Card POS terminal",
|
||||
"posDeviceHint": "On card payment, the amount is sent via HTTP (POST /pay) to the device on your LAN.",
|
||||
"posDeviceIp": "POS device IP address",
|
||||
"detect": "Auto-detect",
|
||||
"detecting": "Scanning the network…",
|
||||
"detectNone": "No devices found on the network",
|
||||
"detectOffline": "A print server must be online to auto-detect",
|
||||
"detectHint": "The print server scans your LAN to find the device.",
|
||||
"testSent": "Test sent to the printer.",
|
||||
"sent": "Sent to the printer.",
|
||||
"noStationItems": "This order has no items for that station.",
|
||||
@@ -370,6 +375,24 @@
|
||||
"empty": "No stations yet. Add Kitchen and Bar to print their items separately.",
|
||||
"deleteConfirm": "Delete station “{name}”? Its categories will fall back to the kitchen printer.",
|
||||
"saveError": "Failed to save the station."
|
||||
},
|
||||
"agents": {
|
||||
"title": "Print servers (auto-discovery)",
|
||||
"hint": "Install the Meezi print agent on the cash PC to auto-detect its USB & network printers and print through it.",
|
||||
"add": "Add print server",
|
||||
"pairingTitle": "Enter this code in the print agent on the cash PC:",
|
||||
"pairingSteps": "Install and run the Meezi Print Agent on the PC connected to the printers, then enter this code. It is valid for 15 minutes.",
|
||||
"empty": "No print server connected yet.",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"noDevices": "Discovering printers…",
|
||||
"test": "Test",
|
||||
"receiptVia": "Receipt printer (via server)",
|
||||
"kitchenVia": "Kitchen printer (via server)",
|
||||
"viaServer": "Printer (via server)",
|
||||
"useIpInstead": "— Use manual IP —",
|
||||
"revokeConfirm": "Remove print server “{name}”? It will no longer be able to print.",
|
||||
"codeError": "Could not create code."
|
||||
}
|
||||
},
|
||||
"receipt": {
|
||||
|
||||
@@ -352,6 +352,11 @@
|
||||
"posDeviceSection": "دستگاه پوز (کارتخوان)",
|
||||
"posDeviceHint": "هنگام پرداخت کارتی، مبلغ به آدرس HTTP دستگاه ارسال میشود (POST /pay).",
|
||||
"posDeviceIp": "آدرس IP دستگاه پوز",
|
||||
"detect": "تشخیص خودکار",
|
||||
"detecting": "در حال جستجوی شبکه…",
|
||||
"detectNone": "دستگاهی در شبکه پیدا نشد",
|
||||
"detectOffline": "برای تشخیص خودکار باید پرینتسرور روشن و متصل باشد",
|
||||
"detectHint": "پرینتسرور شبکه محلی را برای یافتن دستگاه اسکن میکند.",
|
||||
"testSent": "تست به پرینتر ارسال شد.",
|
||||
"sent": "به پرینتر ارسال شد.",
|
||||
"noStationItems": "این سفارش آیتمی برای این ایستگاه ندارد.",
|
||||
@@ -370,6 +375,24 @@
|
||||
"empty": "هنوز ایستگاهی ندارید. «آشپزخانه» و «بار» را اضافه کنید تا آیتمهایشان جدا چاپ شود.",
|
||||
"deleteConfirm": "ایستگاه «{name}» حذف شود؟ دستههای آن به پرینتر آشپزخانه برمیگردند.",
|
||||
"saveError": "ذخیرهٔ ایستگاه ناموفق بود."
|
||||
},
|
||||
"agents": {
|
||||
"title": "پرینتسرورها (شناسایی خودکار پرینتر)",
|
||||
"hint": "روی کامپیوتر صندوق، برنامهٔ «پرینتسرور میزی» را نصب کنید تا پرینترهای USB و شبکه بهصورت خودکار شناسایی شوند و چاپ از طریق آن انجام شود.",
|
||||
"add": "افزودن پرینتسرور",
|
||||
"pairingTitle": "این کد را در برنامهٔ پرینتسرور روی کامپیوتر صندوق وارد کنید:",
|
||||
"pairingSteps": "برنامهٔ «پرینتسرور میزی» را روی همان کامپیوتری که به پرینترها وصل است نصب و اجرا کنید، سپس این کد را وارد کنید. کد تا ۱۵ دقیقه معتبر است.",
|
||||
"empty": "هنوز پرینتسروری متصل نشده است.",
|
||||
"online": "آنلاین",
|
||||
"offline": "آفلاین",
|
||||
"noDevices": "در حال یافتن پرینترها…",
|
||||
"test": "تست",
|
||||
"receiptVia": "پرینتر رسید (از پرینتسرور)",
|
||||
"kitchenVia": "پرینتر آشپزخانه (از پرینتسرور)",
|
||||
"viaServer": "پرینتر (از پرینتسرور)",
|
||||
"useIpInstead": "— استفاده از IP دستی —",
|
||||
"revokeConfirm": "حذف پرینتسرور «{name}»؟ پس از آن دیگر نمیتواند چاپ کند.",
|
||||
"codeError": "ایجاد کد ناموفق بود."
|
||||
}
|
||||
},
|
||||
"receipt": {
|
||||
|
||||
@@ -1,61 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { WifiOff, CloudUpload, RefreshCw } from "lucide-react";
|
||||
import { WifiOff, CloudUpload, RefreshCw, AlertTriangle } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocale } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
||||
import { useLocale } from "next-intl";
|
||||
import { getQueueCount } from "@/lib/offline/offline-db";
|
||||
import {
|
||||
getAllQueueItems,
|
||||
getQueueCount,
|
||||
removeQueueItem,
|
||||
markQueueItemFailed,
|
||||
} from "@/lib/offline/offline-db";
|
||||
import { apiPost } from "@/lib/api/client";
|
||||
|
||||
/** Manual retry — fires one sync pass immediately (used as onClick). */
|
||||
async function runManualSync(
|
||||
setSyncing: (v: boolean) => void,
|
||||
setQueueCount: (n: number) => void
|
||||
) {
|
||||
if (!navigator.onLine) return;
|
||||
setSyncing(true);
|
||||
try {
|
||||
const items = await getAllQueueItems();
|
||||
for (const item of items) {
|
||||
try {
|
||||
if (item.type === "create_order") {
|
||||
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
|
||||
await apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, unknown>);
|
||||
} else if (item.type === "add_items") {
|
||||
const { cafeId, orderId, body } = item.payload as {
|
||||
cafeId: string;
|
||||
orderId: string;
|
||||
body: unknown;
|
||||
};
|
||||
await apiPost(
|
||||
`/api/cafes/${cafeId}/orders/${orderId}/items`,
|
||||
body as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
await removeQueueItem(item.id);
|
||||
} catch {
|
||||
await markQueueItemFailed(item.id);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
setQueueCount(await getQueueCount());
|
||||
}
|
||||
}
|
||||
drainOutbox,
|
||||
getActiveOutboxCount,
|
||||
getFailedOutboxCount,
|
||||
discardFailedOps,
|
||||
} from "@/lib/offline/outbox";
|
||||
|
||||
export function SyncStatusIndicator() {
|
||||
const { queueCount, isSyncing, isOnline, setSyncing, setQueueCount } =
|
||||
useSyncQueueStore();
|
||||
const {
|
||||
queueCount,
|
||||
failedCount,
|
||||
isSyncing,
|
||||
isOnline,
|
||||
setSyncing,
|
||||
setQueueCount,
|
||||
setFailedCount,
|
||||
} = useSyncQueueStore();
|
||||
const queryClient = useQueryClient();
|
||||
const locale = useLocale();
|
||||
const isFa = locale !== "en";
|
||||
|
||||
const show = !isOnline || queueCount > 0 || isSyncing;
|
||||
if (!show) return null;
|
||||
const recount = async () => {
|
||||
setQueueCount((await getActiveOutboxCount()) + (await getQueueCount()));
|
||||
setFailedCount(await getFailedOutboxCount());
|
||||
};
|
||||
|
||||
// Manual retry — drains the REAL outbox (the engine the app actually uses),
|
||||
// then refreshes server data and the counts.
|
||||
const retry = async () => {
|
||||
if (typeof navigator !== "undefined" && !navigator.onLine) return;
|
||||
if (isSyncing) return;
|
||||
setSyncing(true);
|
||||
try {
|
||||
const res = await drainOutbox();
|
||||
if (res.sent > 0) await queryClient.invalidateQueries();
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
await recount();
|
||||
}
|
||||
};
|
||||
|
||||
// Poisoned ops can never sync (permanent 4xx) — let the user clear them so the
|
||||
// badge doesn't sit stuck forever.
|
||||
const clearFailed = async () => {
|
||||
await discardFailedOps();
|
||||
await recount();
|
||||
};
|
||||
|
||||
const showPending = !isOnline || queueCount > 0 || isSyncing;
|
||||
const showFailed = !showPending && failedCount > 0;
|
||||
if (!showPending && !showFailed) return null;
|
||||
|
||||
if (showFailed) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void clearFailed()}
|
||||
title={isFa ? "حذف موارد ناموفق همگامسازی" : "Clear failed sync items"}
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-full bg-red-100 px-2.5 py-1 text-[11px] font-medium text-red-800 transition-colors hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300"
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3 shrink-0" aria-hidden />
|
||||
<span>{isFa ? `${failedCount} ناموفق — پاک کردن` : `${failedCount} failed — clear`}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const label = isFa
|
||||
? !isOnline
|
||||
@@ -72,13 +87,9 @@ export function SyncStatusIndicator() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runManualSync(setSyncing, setQueueCount)}
|
||||
onClick={() => void retry()}
|
||||
disabled={isSyncing || !isOnline}
|
||||
title={
|
||||
isFa
|
||||
? "برای همگامسازی دستی کلیک کنید"
|
||||
: "Click to retry sync"
|
||||
}
|
||||
title={isFa ? "برای همگامسازی دستی کلیک کنید" : "Click to retry sync"}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors",
|
||||
"disabled:cursor-not-allowed",
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Search, Plus, Minus, Trash2, Send, CreditCard, SplitSquareHorizontal,
|
||||
X, WifiOff, ShoppingCart, Users, Coffee, ArrowRight, LayoutGrid, Armchair,
|
||||
Banknote, Check, Delete, ReceiptText, ShoppingBag, Loader2,
|
||||
BadgePercent, Sparkles, Home, StickyNote,
|
||||
BadgePercent, Sparkles, Home, StickyNote, Lock,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { notify } from "@/lib/notify";
|
||||
@@ -26,7 +26,9 @@ import { submitOrderToApi, orderAmountDue, isLocalOrder } from "@/lib/pos2/submi
|
||||
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
|
||||
import { printReceipt } from "@/lib/api/print";
|
||||
import { PosCustomerPicker } from "@/components/pos2/pos-customer-picker";
|
||||
import { useConfirm } from "@/components/providers/confirm-provider";
|
||||
import { Can } from "@/components/auth/can";
|
||||
import { useHasPermission } from "@/lib/permissions";
|
||||
import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types";
|
||||
import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2";
|
||||
|
||||
@@ -111,6 +113,12 @@ export function Pos2Screen() {
|
||||
const activeOrderId = useCartStore((s) => s.activeOrderId);
|
||||
const appliedCoupon = useCartStore((s) => s.appliedCoupon);
|
||||
|
||||
// Removing/reducing an item that's already been fired to the kitchen is a void —
|
||||
// a cashier must NOT be able to do it (send food, then erase it). Gated on the
|
||||
// VoidOrder permission; the unsent portion of a line stays freely editable.
|
||||
const canVoid = useHasPermission("VoidOrder");
|
||||
const confirm = useConfirm();
|
||||
|
||||
// local view state
|
||||
const [view, setView] = useState<"board" | "order">("board");
|
||||
const [activeTable, setActiveTable] = useState<TableBoardItem | null>(null);
|
||||
@@ -282,9 +290,31 @@ export function Pos2Screen() {
|
||||
try {
|
||||
const cardTotal = payments.filter((p) => p.method === "Card").reduce((s, p) => s + p.amount, 0);
|
||||
const payBranchId = payTarget.branchId ?? orderBranchId ?? undefined;
|
||||
// Card leg: push the amount to the configured terminal and wait for it. A
|
||||
// connected terminal that declines throws POS_DEVICE_* (caught below →
|
||||
// nothing recorded). If no terminal is wired up the request is "skipped",
|
||||
// so we have NO machine proof the card actually cleared.
|
||||
let cardConfirmedByTerminal = false;
|
||||
if (cardTotal > 0 && payBranchId) {
|
||||
// push the card amount to the configured terminal (no-op/skip if none)
|
||||
await requestPosPayment(cafeId as string, payBranchId, payTarget.id, cardTotal);
|
||||
const res = await requestPosPayment(cafeId as string, payBranchId, payTarget.id, cardTotal);
|
||||
cardConfirmedByTerminal = res.sent && !res.skipped;
|
||||
}
|
||||
// No integrated-terminal confirmation → make the cashier confirm the card
|
||||
// was approved before we book it as paid; otherwise a declined card gets
|
||||
// recorded as revenue the café never received.
|
||||
if (cardTotal > 0 && !cardConfirmedByTerminal) {
|
||||
setBusy(false); // hide the processing overlay so the dialog is interactive
|
||||
const approved = await confirm({
|
||||
title: "تأیید پرداخت کارتی",
|
||||
description: `پرداخت کارتی ${fmt(cardTotal)} تومان روی دستگاه پوز با موفقیت انجام شد؟`,
|
||||
confirmLabel: "بله، پرداخت شد",
|
||||
cancelLabel: "خیر، لغو",
|
||||
});
|
||||
if (!approved) {
|
||||
notify.error("ثبت پرداخت لغو شد");
|
||||
return; // finally resets the guards; nothing recorded
|
||||
}
|
||||
setBusy(true);
|
||||
}
|
||||
await apiPost(`/api/cafes/${cafeId}/orders/${payTarget.id}/payments`, {
|
||||
payments,
|
||||
@@ -361,12 +391,31 @@ export function Pos2Screen() {
|
||||
|
||||
const ticketProps = {
|
||||
cafeId,
|
||||
canVoid,
|
||||
// mark fully-sent lines so their note becomes read-only (a note-only change on
|
||||
// an already-sent line would otherwise be silently dropped on the next send).
|
||||
lines: live.map((l) => ({ ...l, synced: (syncedQty[l.menuItem.id] ?? 0) >= l.quantity })),
|
||||
// sentQty = how much of this line is already fired to the kitchen (the locked
|
||||
// portion a non-void user may neither remove nor reduce below).
|
||||
lines: live.map((l) => ({
|
||||
...l,
|
||||
sentQty: syncedQty[l.menuItem.id] ?? 0,
|
||||
synced: (syncedQty[l.menuItem.id] ?? 0) >= l.quantity,
|
||||
})),
|
||||
subtotal, discount, tax, total, count, pendingCount,
|
||||
onBump: (id: string, d: number) => { const l = items.find((x) => x.menuItem.id === id); if (l) updateQty(id, l.quantity + d); },
|
||||
onRemove: removeItem, onSend: send, onPay: openPay, onSplit: openPay,
|
||||
onBump: (id: string, d: number) => {
|
||||
const l = items.find((x) => x.menuItem.id === id);
|
||||
if (!l) return;
|
||||
const sent = syncedQty[id] ?? 0;
|
||||
// Block reducing below what's already been sent unless the user can void.
|
||||
if (!canVoid && l.quantity + d < sent) return;
|
||||
updateQty(id, l.quantity + d);
|
||||
},
|
||||
onRemove: (id: string) => {
|
||||
const sent = syncedQty[id] ?? 0;
|
||||
if (!canVoid && sent > 0) return; // can't delete an item already sent to the kitchen
|
||||
removeItem(id);
|
||||
},
|
||||
onSend: send, onPay: openPay, onSplit: openPay,
|
||||
onNote: (id: string, notes: string) => setNotes(id, notes),
|
||||
canPrint: !!activeOrderId && !isLocalOrder(activeOrderId),
|
||||
onPrintReceipt: printActiveReceipt,
|
||||
@@ -620,7 +669,15 @@ export function Pos2Screen() {
|
||||
{payTarget && (
|
||||
<Pos2PaySheet
|
||||
tableName={title}
|
||||
amountDue={orderAmountDue(payTarget) || total}
|
||||
// Charge the server's authoritative outstanding amount. Only a
|
||||
// genuinely-local (offline) order has no server figure to trust, so
|
||||
// only then fall back to the client-computed total. Never silently
|
||||
// swap a real order's server amount for the POS's own 9% recompute.
|
||||
amountDue={
|
||||
isLocalOrder(payTarget.id)
|
||||
? orderAmountDue(payTarget) || total
|
||||
: orderAmountDue(payTarget)
|
||||
}
|
||||
loyaltyPoints={payLoyalty}
|
||||
onClose={() => setPayTarget(null)}
|
||||
onConfirm={confirmPay}
|
||||
@@ -741,11 +798,11 @@ function Pos2Extras({ cafeId }: { cafeId: string }) {
|
||||
}
|
||||
|
||||
// ── Order ticket ─────────────────────────────────────────────────────────────
|
||||
type TicketLine = { menuItem: MenuItem; quantity: number; notes?: string; synced?: boolean };
|
||||
type TicketLine = { menuItem: MenuItem; quantity: number; notes?: string; synced?: boolean; sentQty?: number };
|
||||
function Ticket({
|
||||
cafeId, lines, subtotal, discount, tax, total, count, pendingCount, onBump, onRemove, onNote, onSend, onPay, onSplit, canPrint, onPrintReceipt,
|
||||
cafeId, canVoid, lines, subtotal, discount, tax, total, count, pendingCount, onBump, onRemove, onNote, onSend, onPay, onSplit, canPrint, onPrintReceipt,
|
||||
}: {
|
||||
cafeId: string; lines: TicketLine[]; subtotal: number; discount: number; tax: number; total: number;
|
||||
cafeId: string; canVoid: boolean; lines: TicketLine[]; subtotal: number; discount: number; tax: number; total: number;
|
||||
count: number; pendingCount: number;
|
||||
onBump: (id: string, d: number) => void; onRemove: (id: string) => void;
|
||||
onNote: (id: string, notes: string) => void;
|
||||
@@ -771,8 +828,21 @@ function Ticket({
|
||||
<p className="line-clamp-1 font-medium">{l.menuItem.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{fmt(l.menuItem.price)} تومان</p>
|
||||
</div>
|
||||
{(() => {
|
||||
const sent = l.sentQty ?? 0;
|
||||
const minusLocked = !canVoid && l.quantity <= sent;
|
||||
const removeLocked = !canVoid && sent > 0;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" onClick={() => onBump(l.menuItem.id, -1)} className="flex size-11 items-center justify-center rounded-lg bg-muted hover:bg-accent active:scale-95" aria-label="کم">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBump(l.menuItem.id, -1)}
|
||||
disabled={minusLocked}
|
||||
title={minusLocked ? "این تعداد به آشپزخانه ارسال شده و قابل کاهش نیست" : undefined}
|
||||
className="flex size-11 items-center justify-center rounded-lg bg-muted hover:bg-accent active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-muted"
|
||||
aria-label="کم"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</button>
|
||||
<span className="w-7 text-center font-bold">{fmt(l.quantity)}</span>
|
||||
@@ -780,9 +850,22 @@ function Ticket({
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{removeLocked ? (
|
||||
<span
|
||||
className="flex size-9 items-center justify-center rounded-lg text-muted-foreground/60"
|
||||
title="به آشپزخانه ارسال شده — برای حذف نیاز به دسترسی ابطال است"
|
||||
aria-label="ارسالشده؛ قابل حذف نیست"
|
||||
>
|
||||
<Lock className="size-4" />
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" onClick={() => onRemove(l.menuItem.id)} className="flex size-9 items-center justify-center rounded-lg text-red-500 hover:bg-red-50" aria-label="حذف">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{l.synced ? (
|
||||
// Already sent to the kitchen — note is read-only (can't be changed now).
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Server, Wifi, WifiOff, Trash2, Plus, Loader2, Radar } from "lucide-react";
|
||||
import { apiGet, apiPatch } from "@/lib/api/client";
|
||||
import {
|
||||
listPrintAgents,
|
||||
createPairingCode,
|
||||
revokePrintAgent,
|
||||
testPrintDevice,
|
||||
deviceOptions,
|
||||
scanNetwork,
|
||||
type PairingCode,
|
||||
type ScannedDevice,
|
||||
} from "@/lib/api/print-agents";
|
||||
import { ApiClientError } from "@/lib/api/client";
|
||||
import { printErrorMessage } from "@/lib/api/print";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { useConfirm } from "@/components/providers/confirm-provider";
|
||||
import { notify } from "@/lib/notify";
|
||||
|
||||
type BranchPrintSettings = {
|
||||
branchId: string;
|
||||
@@ -22,6 +37,8 @@ type BranchPrintSettings = {
|
||||
wifiPassword?: string | null;
|
||||
posDeviceIp?: string | null;
|
||||
posDevicePort?: number | null;
|
||||
receiptPrintDeviceId?: string | null;
|
||||
kitchenPrintDeviceId?: string | null;
|
||||
};
|
||||
|
||||
type SettingsPrinterPanelProps = {
|
||||
@@ -46,6 +63,15 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
||||
const [wifiPassword, setWifiPassword] = useState("");
|
||||
const [posDeviceIp, setPosDeviceIp] = useState("");
|
||||
const [posDevicePort, setPosDevicePort] = useState("8088");
|
||||
const [receiptDeviceId, setReceiptDeviceId] = useState("");
|
||||
const [kitchenDeviceId, setKitchenDeviceId] = useState("");
|
||||
|
||||
const { data: agents = [] } = useQuery({
|
||||
queryKey: ["print-agents", cafeId],
|
||||
queryFn: () => listPrintAgents(cafeId),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
const devices = deviceOptions(agents);
|
||||
|
||||
const { data: branches = [], isLoading: branchesLoading } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
@@ -77,6 +103,8 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
||||
setWifiPassword(settings.wifiPassword ?? "");
|
||||
setPosDeviceIp(settings.posDeviceIp ?? "");
|
||||
setPosDevicePort(String(settings.posDevicePort ?? 8088));
|
||||
setReceiptDeviceId(settings.receiptPrintDeviceId ?? "");
|
||||
setKitchenDeviceId(settings.kitchenPrintDeviceId ?? "");
|
||||
}, [settings]);
|
||||
|
||||
const save = useMutation({
|
||||
@@ -95,6 +123,8 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
||||
wifiPassword: wifiPassword.trim() || null,
|
||||
posDeviceIp: posDeviceIp.trim() || null,
|
||||
posDevicePort: parseInt(posDevicePort, 10) || 8088,
|
||||
receiptPrintDeviceId: receiptDeviceId || null,
|
||||
kitchenPrintDeviceId: kitchenDeviceId || null,
|
||||
}
|
||||
),
|
||||
onSuccess: () => {
|
||||
@@ -103,6 +133,37 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
||||
},
|
||||
});
|
||||
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [pairing, setPairing] = useState<PairingCode | null>(null);
|
||||
|
||||
const createCode = useMutation({
|
||||
mutationFn: () => createPairingCode(cafeId),
|
||||
onSuccess: (c) => {
|
||||
setPairing(c);
|
||||
void qc.invalidateQueries({ queryKey: ["print-agents", cafeId] });
|
||||
},
|
||||
onError: () => notify.error(t("agents.codeError")),
|
||||
});
|
||||
const revoke = useMutation({
|
||||
mutationFn: (id: string) => revokePrintAgent(cafeId, id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["print-agents", cafeId] }),
|
||||
});
|
||||
const testDevice = useMutation({
|
||||
mutationFn: (deviceId: string) => testPrintDevice(cafeId, deviceId),
|
||||
onSuccess: () => notify.success(t("testSent")),
|
||||
onError: (err) => notify.error(printErrorMessage(err, t)),
|
||||
});
|
||||
|
||||
const handleRevoke = async (id: string, name: string) => {
|
||||
const ok = await confirm({
|
||||
description: t("agents.revokeConfirm", { name }),
|
||||
variant: "destructive",
|
||||
confirmLabel: tCommon("confirm"),
|
||||
});
|
||||
if (ok) revoke.mutate(id);
|
||||
};
|
||||
|
||||
if (branchesLoading) {
|
||||
return <p className="text-sm text-muted-foreground">{tCommon("loading")}</p>;
|
||||
}
|
||||
@@ -134,6 +195,127 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Print servers — auto-discovered printers via the local agent */}
|
||||
<section className="space-y-3 rounded-lg border border-border/80 bg-muted/20 p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Server className="size-4 text-[#0F6E56]" />
|
||||
{t("agents.title")}
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{t("agents.hint")}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0 gap-1.5"
|
||||
disabled={createCode.isPending}
|
||||
onClick={() => createCode.mutate()}
|
||||
>
|
||||
{createCode.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
{t("agents.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pairing ? (
|
||||
<div className="rounded-lg border border-[#0F6E56]/30 bg-[#E1F5EE]/50 p-3 text-sm dark:bg-[#0F6E56]/10">
|
||||
<p className="font-medium">{t("agents.pairingTitle")}</p>
|
||||
<p className="my-2 text-center font-mono text-2xl font-bold tracking-[0.3em] text-[#0F6E56]">
|
||||
{pairing.code}
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{t("agents.pairingSteps")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{agents.length === 0 ? (
|
||||
<p className="py-2 text-center text-xs text-muted-foreground">{t("agents.empty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{agents.map((a) => (
|
||||
<li key={a.id} className="rounded-lg border border-border/70 bg-background p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{a.online ? (
|
||||
<Wifi className="size-4 shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<WifiOff className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{a.name}</span>
|
||||
<span className={a.online ? "text-[11px] text-emerald-600" : "text-[11px] text-muted-foreground"}>
|
||||
{a.online ? t("agents.online") : t("agents.offline")}
|
||||
</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
disabled={revoke.isPending}
|
||||
onClick={() => handleRevoke(a.id, a.name)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{a.devices.length > 0 ? (
|
||||
<ul className="mt-2 space-y-1 ps-6">
|
||||
{a.devices.map((d) => (
|
||||
<li key={d.id} className="flex items-center gap-2 text-xs">
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
{d.displayName} <span className="opacity-60">({d.kind})</span>
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
disabled={!a.online || testDevice.isPending}
|
||||
onClick={() => testDevice.mutate(d.id)}
|
||||
>
|
||||
{t("agents.test")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : a.online ? (
|
||||
<p className="mt-1 ps-6 text-[11px] text-muted-foreground">{t("agents.noDevices")}</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{devices.length > 0 ? (
|
||||
<div className="grid gap-4 border-t border-border/60 pt-3 sm:grid-cols-2">
|
||||
<LabeledField label={t("agents.receiptVia")} htmlFor="receipt-device">
|
||||
<select
|
||||
id="receipt-device"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={receiptDeviceId}
|
||||
onChange={(e) => setReceiptDeviceId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("agents.useIpInstead")}</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.label}{d.online ? "" : ` (${t("agents.offline")})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("agents.kitchenVia")} htmlFor="kitchen-device">
|
||||
<select
|
||||
id="kitchen-device"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={kitchenDeviceId}
|
||||
onChange={(e) => setKitchenDeviceId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("agents.useIpInstead")}</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.label}{d.online ? "" : ` (${t("agents.offline")})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledField label={t("receiptPrinter")} htmlFor="receipt-ip">
|
||||
@@ -145,6 +327,14 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
<DetectButton
|
||||
cafeId={cafeId}
|
||||
ports="9100"
|
||||
onPick={(ip, port) => {
|
||||
setReceiptIp(ip);
|
||||
setReceiptPort(String(port));
|
||||
}}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("port")} htmlFor="receipt-port">
|
||||
<Input
|
||||
@@ -164,6 +354,14 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
<DetectButton
|
||||
cafeId={cafeId}
|
||||
ports="9100"
|
||||
onPick={(ip, port) => {
|
||||
setKitchenIp(ip);
|
||||
setKitchenPort(String(port));
|
||||
}}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("port")} htmlFor="kitchen-port">
|
||||
<Input
|
||||
@@ -242,6 +440,14 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
<DetectButton
|
||||
cafeId={cafeId}
|
||||
ports={posDevicePort.trim() || "8088"}
|
||||
onPick={(ip, port) => {
|
||||
setPosDeviceIp(ip);
|
||||
setPosDevicePort(String(port));
|
||||
}}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("port")} htmlFor="pos-device-port">
|
||||
<Input
|
||||
@@ -268,3 +474,75 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Auto-detect" affordance for an IP field: asks the online print agent to scan
|
||||
* the café LAN for the given ports and lets the owner pick a found host (which
|
||||
* fills the IP + port) instead of typing an address by hand.
|
||||
*/
|
||||
function DetectButton({
|
||||
cafeId,
|
||||
ports,
|
||||
onPick,
|
||||
}: {
|
||||
cafeId: string;
|
||||
ports: string;
|
||||
onPick: (ip: string, port: number) => void;
|
||||
}) {
|
||||
const t = useTranslations("print");
|
||||
const [results, setResults] = useState<ScannedDevice[] | null>(null);
|
||||
|
||||
const scan = useMutation({
|
||||
mutationFn: () => scanNetwork(cafeId, ports),
|
||||
onSuccess: (devices) => setResults(devices),
|
||||
onError: (e) =>
|
||||
notify.error(
|
||||
e instanceof ApiClientError && e.code === "AGENT_OFFLINE"
|
||||
? t("detectOffline")
|
||||
: t("detectNone"),
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setResults(null);
|
||||
scan.mutate();
|
||||
}}
|
||||
disabled={scan.isPending}
|
||||
title={t("detectHint")}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-input px-2.5 py-1 text-xs font-medium text-muted-foreground hover:bg-accent disabled:opacity-60"
|
||||
>
|
||||
{scan.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Radar className="size-3.5" />
|
||||
)}
|
||||
{scan.isPending ? t("detecting") : t("detect")}
|
||||
</button>
|
||||
{results &&
|
||||
(results.length === 0 ? (
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">{t("detectNone")}</p>
|
||||
) : (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{results.map((d) => (
|
||||
<button
|
||||
key={`${d.ip}:${d.port}`}
|
||||
type="button"
|
||||
dir="ltr"
|
||||
onClick={() => {
|
||||
onPick(d.ip, d.port);
|
||||
setResults(null);
|
||||
}}
|
||||
className="block w-full rounded-md border border-border/70 bg-background px-2.5 py-1 text-start font-mono text-xs hover:border-primary hover:bg-primary/5"
|
||||
>
|
||||
{d.ip}:{d.port}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type KitchenStation,
|
||||
} from "@/lib/api/kitchen-stations";
|
||||
import { testPrinter, printErrorMessage } from "@/lib/api/print";
|
||||
import { listPrintAgents, deviceOptions } from "@/lib/api/print-agents";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -37,6 +38,14 @@ function StationForm({
|
||||
const [name, setName] = useState(station?.name ?? "");
|
||||
const [ip, setIp] = useState(station?.printerIp ?? "");
|
||||
const [port, setPort] = useState(String(station?.printerPort ?? 9100));
|
||||
const [deviceId, setDeviceId] = useState(station?.printDeviceId ?? "");
|
||||
|
||||
const { data: agents = [] } = useQuery({
|
||||
queryKey: ["print-agents", cafeId],
|
||||
queryFn: () => listPrintAgents(cafeId),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
const devices = deviceOptions(agents);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
@@ -44,6 +53,7 @@ function StationForm({
|
||||
name: name.trim(),
|
||||
printerIp: ip.trim() || null,
|
||||
printerPort: parseInt(port, 10) || 9100,
|
||||
printDeviceId: deviceId || null,
|
||||
};
|
||||
return station
|
||||
? updateKitchenStation(cafeId, station.id, body)
|
||||
@@ -88,6 +98,23 @@ function StationForm({
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
{devices.length > 0 ? (
|
||||
<LabeledField label={t("agents.viaServer")} htmlFor="station-device">
|
||||
<select
|
||||
id="station-device"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={deviceId}
|
||||
onChange={(e) => setDeviceId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("agents.useIpInstead")}</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.label}{d.online ? "" : ` (${t("agents.offline")})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
) : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onDone} disabled={save.isPending}>
|
||||
{tCommon("cancel")}
|
||||
|
||||
@@ -16,7 +16,9 @@ const AlertDialogOverlay = React.forwardRef<
|
||||
<AlertDialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/45 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
// z-[80]: a confirmation must sit above app overlays (the POS pay sheet is
|
||||
// z-[60] and its busy overlay z-[70]); stays below toasts.
|
||||
"fixed inset-0 z-[80] bg-black/45 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -33,7 +35,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border/80 bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"fixed left-[50%] top-[50%] z-[80] grid w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border/80 bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface KitchenStation {
|
||||
printerPort: number;
|
||||
sortOrder: number;
|
||||
categoryCount: number;
|
||||
printDeviceId?: string | null;
|
||||
}
|
||||
|
||||
export function fetchKitchenStations(cafeId: string): Promise<KitchenStation[]> {
|
||||
@@ -22,7 +23,7 @@ export function fetchKitchenStations(cafeId: string): Promise<KitchenStation[]>
|
||||
|
||||
export function createKitchenStation(
|
||||
cafeId: string,
|
||||
body: { name: string; printerIp?: string | null; printerPort?: number; sortOrder?: number }
|
||||
body: { name: string; printerIp?: string | null; printerPort?: number; sortOrder?: number; printDeviceId?: string | null }
|
||||
): Promise<KitchenStation> {
|
||||
return apiPost<KitchenStation>(`/api/cafes/${cafeId}/kitchen-stations`, body);
|
||||
}
|
||||
@@ -30,7 +31,7 @@ export function createKitchenStation(
|
||||
export function updateKitchenStation(
|
||||
cafeId: string,
|
||||
id: string,
|
||||
body: { name?: string; printerIp?: string | null; printerPort?: number | null; sortOrder?: number | null }
|
||||
body: { name?: string; printerIp?: string | null; printerPort?: number | null; sortOrder?: number | null; printDeviceId?: string | null }
|
||||
): Promise<KitchenStation> {
|
||||
return apiPatch<KitchenStation>(`/api/cafes/${cafeId}/kitchen-stations/${id}`, body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { apiGet, apiPost, apiDelete } from "@/lib/api/client";
|
||||
|
||||
/** A printer discovered & reported by a local print agent. */
|
||||
export interface PrintAgentDevice {
|
||||
id: string;
|
||||
systemName: string;
|
||||
displayName: string;
|
||||
kind: string; // "usb" | "network" | "other"
|
||||
lastSeenAt: string;
|
||||
}
|
||||
|
||||
/** A local print bridge (cash-PC app) paired to the café. */
|
||||
export interface PrintAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
branchId?: string | null;
|
||||
online: boolean;
|
||||
paired: boolean;
|
||||
lastSeenAt?: string | null;
|
||||
createdAt: string;
|
||||
devices: PrintAgentDevice[];
|
||||
}
|
||||
|
||||
export interface PairingCode {
|
||||
agentId: string;
|
||||
code: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/** A flattened device option for a printer dropdown. */
|
||||
export interface DeviceOption {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export function listPrintAgents(cafeId: string): Promise<PrintAgent[]> {
|
||||
return apiGet<PrintAgent[]>(`/api/cafes/${cafeId}/print-agents`);
|
||||
}
|
||||
|
||||
export function createPairingCode(cafeId: string, name?: string): Promise<PairingCode> {
|
||||
return apiPost<PairingCode>(`/api/cafes/${cafeId}/print-agents/pairing-code`, {
|
||||
name: name?.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
export function revokePrintAgent(cafeId: string, id: string): Promise<void> {
|
||||
return apiDelete(`/api/cafes/${cafeId}/print-agents/${id}`);
|
||||
}
|
||||
|
||||
export function testPrintDevice(cafeId: string, deviceId: string): Promise<unknown> {
|
||||
return apiPost(`/api/cafes/${cafeId}/print-agents/devices/${deviceId}/test`, {});
|
||||
}
|
||||
|
||||
/** A host found on the café LAN by an online agent's network scan. */
|
||||
export interface ScannedDevice {
|
||||
ip: string;
|
||||
port: number;
|
||||
kind: string; // "network-printer" | "pos-terminal" | "other"
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the café's online print agent(s) to scan the LAN for devices on the given
|
||||
* comma-separated ports (e.g. "9100" for network printers, "8088" for terminals).
|
||||
* Throws AGENT_OFFLINE if no agent is connected to do the scan.
|
||||
*/
|
||||
export function scanNetwork(cafeId: string, ports: string): Promise<ScannedDevice[]> {
|
||||
return apiPost<ScannedDevice[]>(`/api/cafes/${cafeId}/print-agents/scan`, { ports });
|
||||
}
|
||||
|
||||
/** Every device across all agents, for a printer-picker dropdown. */
|
||||
export function deviceOptions(agents: PrintAgent[]): DeviceOption[] {
|
||||
return agents.flatMap((a) =>
|
||||
a.devices.map((d) => ({
|
||||
id: d.id,
|
||||
label: `${a.name} · ${d.displayName}`,
|
||||
kind: d.kind,
|
||||
online: a.online,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,9 @@ export function useNotificationsFeed(options: UseNotificationsFeedOptions = {})
|
||||
queryFn: () => fetchNotifications(cafeId!, unreadOnly, limit),
|
||||
enabled: !!cafeId,
|
||||
refetchInterval: 60_000,
|
||||
// Keep polling even when the tab is in the background, so the unread count
|
||||
// stays current if the live socket missed something — no refresh needed.
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
|
||||
@@ -94,6 +94,7 @@ export function useTabBadge() {
|
||||
queryFn: () => fetchNotifications(cafeId!, false, 50),
|
||||
enabled: !!cafeId,
|
||||
refetchInterval: 60_000,
|
||||
refetchIntervalInBackground: true, // update the tab badge even when unfocused
|
||||
});
|
||||
|
||||
const unread = data?.unreadCount ?? 0;
|
||||
|
||||
@@ -165,3 +165,24 @@ export async function getPoisonedOps(): Promise<OutboxOp[]> {
|
||||
const ops = await getOutboxOps();
|
||||
return ops.filter((o) => o.status === "failed" && o.attempts >= MAX_ATTEMPTS);
|
||||
}
|
||||
|
||||
function isPoisoned(o: OutboxOp): boolean {
|
||||
return o.status === "failed" && o.attempts >= MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
/** Count of ops still worth retrying (excludes poisoned) — drives the "pending" badge. */
|
||||
export async function getActiveOutboxCount(): Promise<number> {
|
||||
return (await getOutboxOps()).filter((o) => !isPoisoned(o)).length;
|
||||
}
|
||||
|
||||
/** Count of poisoned ops — surfaced separately so they don't inflate "pending". */
|
||||
export async function getFailedOutboxCount(): Promise<number> {
|
||||
return (await getOutboxOps()).filter(isPoisoned).length;
|
||||
}
|
||||
|
||||
/** Permanently drop poisoned ops (user-initiated "clear failed"). Returns how many. */
|
||||
export async function discardFailedOps(): Promise<number> {
|
||||
const poisoned = (await getOutboxOps()).filter(isPoisoned);
|
||||
for (const o of poisoned) await removeOutboxOp(o.id);
|
||||
return poisoned.length;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
||||
import {
|
||||
enqueueOutboxOp,
|
||||
getAllQueueItems,
|
||||
getOutboxCount,
|
||||
getQueueCount,
|
||||
removeQueueItem,
|
||||
} from "@/lib/offline/offline-db";
|
||||
import { drainOutbox } from "@/lib/offline/outbox";
|
||||
import {
|
||||
drainOutbox,
|
||||
getActiveOutboxCount,
|
||||
getFailedOutboxCount,
|
||||
} from "@/lib/offline/outbox";
|
||||
|
||||
function newId(prefix: string): string {
|
||||
if (prefix === "idem" && typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
@@ -75,15 +78,18 @@ async function migrateLegacyQueue(): Promise<void> {
|
||||
* - refresh server data once writes have synced.
|
||||
*/
|
||||
export function useOfflineSync() {
|
||||
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore();
|
||||
const { setQueueCount, setFailedCount, setSyncing, setOnline } = useSyncQueueStore();
|
||||
const queryClient = useQueryClient();
|
||||
const syncLock = useRef(false);
|
||||
|
||||
const refreshCount = useCallback(async () => {
|
||||
const n = (await getOutboxCount()) + (await getQueueCount());
|
||||
// Pending = retryable ops only; poisoned (failed after max retries) are shown
|
||||
// separately so they never inflate the badge or keep it stuck above zero.
|
||||
const n = (await getActiveOutboxCount()) + (await getQueueCount());
|
||||
setQueueCount(n);
|
||||
setFailedCount(await getFailedOutboxCount());
|
||||
return n;
|
||||
}, [setQueueCount]);
|
||||
}, [setQueueCount, setFailedCount]);
|
||||
|
||||
const syncQueue = useCallback(async () => {
|
||||
if (syncLock.current) return;
|
||||
|
||||
@@ -54,10 +54,23 @@ export function useOrderAlerts() {
|
||||
accessTokenFactory: () =>
|
||||
(typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null) ?? "",
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
// Retry FOREVER (capped backoff). The default policy gives up after ~30s,
|
||||
// leaving the connection dead until a page refresh → missed notifications.
|
||||
.withAutomaticReconnect({
|
||||
nextRetryDelayInMilliseconds: (ctx) =>
|
||||
Math.min(30000, 1000 * 2 ** Math.min(ctx.previousRetryCount, 5)),
|
||||
})
|
||||
.build();
|
||||
|
||||
let stopped = false;
|
||||
const joinCafe = () => connection.invoke("JoinCafe", cafeId).catch(() => {});
|
||||
|
||||
// On reconnect the server group membership is gone — re-join or we silently
|
||||
// stop receiving notifications. Also catch up anything missed while down.
|
||||
connection.onreconnected(() => {
|
||||
void joinCafe();
|
||||
void qc.invalidateQueries({ queryKey: ["notifications", cafeId] });
|
||||
});
|
||||
|
||||
const severityFor = (type: string) => {
|
||||
if (type === "table_call_waiter") return notify.warning;
|
||||
@@ -104,14 +117,36 @@ export function useOrderAlerts() {
|
||||
void connection
|
||||
.start()
|
||||
.then(() => {
|
||||
if (!stopped) return connection.invoke("JoinCafe", cafeId);
|
||||
if (!stopped) return joinCafe();
|
||||
})
|
||||
.catch(() => {
|
||||
// connection/auth failed — alerts simply won't fire; no UI breakage
|
||||
});
|
||||
|
||||
// If the connection fully dropped (gave up, or the device slept), bring it
|
||||
// back when the network returns or the tab is focused again.
|
||||
const ensureConnected = () => {
|
||||
if (stopped) return;
|
||||
if (connection.state === signalR.HubConnectionState.Disconnected) {
|
||||
void connection
|
||||
.start()
|
||||
.then(() => {
|
||||
if (!stopped) return joinCafe();
|
||||
})
|
||||
.then(() => qc.invalidateQueries({ queryKey: ["notifications", cafeId] }))
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") ensureConnected();
|
||||
};
|
||||
window.addEventListener("online", ensureConnected);
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
window.removeEventListener("online", ensureConnected);
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
void connection.stop();
|
||||
};
|
||||
}, [cafeId, locale, qc]);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface SyncQueueState {
|
||||
/** Number of items waiting to be synced */
|
||||
/** Number of items waiting to be synced (retryable; excludes poisoned) */
|
||||
queueCount: number;
|
||||
/** Ops that exhausted retries and need attention (won't auto-sync) */
|
||||
failedCount: number;
|
||||
/** True while a sync pass is running */
|
||||
isSyncing: boolean;
|
||||
/** Mirrors navigator.onLine (updated client-side) */
|
||||
isOnline: boolean;
|
||||
|
||||
setQueueCount: (n: number) => void;
|
||||
setFailedCount: (n: number) => void;
|
||||
setSyncing: (v: boolean) => void;
|
||||
setOnline: (v: boolean) => void;
|
||||
incrementQueue: () => void;
|
||||
@@ -17,10 +20,12 @@ interface SyncQueueState {
|
||||
|
||||
export const useSyncQueueStore = create<SyncQueueState>((set) => ({
|
||||
queueCount: 0,
|
||||
failedCount: 0,
|
||||
isSyncing: false,
|
||||
isOnline: true, // assume online until client hydrates
|
||||
|
||||
setQueueCount: (n) => set({ queueCount: Math.max(0, n) }),
|
||||
setFailedCount: (n) => set({ failedCount: Math.max(0, n) }),
|
||||
setSyncing: (v) => set({ isSyncing: v }),
|
||||
setOnline: (v) => set({ isOnline: v }),
|
||||
incrementQueue: () => set((s) => ({ queueCount: s.queueCount + 1 })),
|
||||
|
||||
Reference in New Issue
Block a user