feat(pos): bridge the card terminal through the print agent + LAN auto-detect
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m29s
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m29s
The card-terminal integration only ever worked when the API could reach the terminal's IP directly — impossible for the cloud deployment, where the terminal sits on the café LAN (the same wall the Print Agent already climbs for printers). And the terminal IP had to be typed by hand. Both fixed by reusing the agent. Cloud→LAN relay: - PrintAgentRegistry.SendPaymentAsync sends a PaymentRequest to the café's online agent and awaits its ack (PaymentResult on the hub); 95s window for the customer. - PosDeviceService now prefers an online agent (branch-matched, else any café agent) to relay POST /pay over the LAN, and falls back to the direct HTTP call only when no agent is connected (on-prem). Agent errors map back to POS_DEVICE_*. - Agent (Program.cs + PosTerminal.cs) handles PaymentRequest → POSTs the amount to the terminal's local http://ip:port/pay and reports approval/decline/timeout. Auto-detect: - Registry.ScanAsync + hub ReportScan; POST /print-agents/scan asks online agents to scan their /24 for given ports and merges the hosts found. - Agent NetworkScanner scans the LAN (:9100 printers, :8088 terminals) with a short per-host TCP probe. - Dashboard: a "تشخیص خودکار" (auto-detect) button on the POS-device, receipt and kitchen IP fields scans via the agent and fills the IP:port from a found host. Backend + agent build clean; dashboard tsc clean. NOTE: the agent app is not in CI — it must be rebuilt and redeployed on the café PC to gain these handlers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -333,6 +333,11 @@
|
||||
"posDeviceSection": "جهاز نقطة البيع (بطاقة)",
|
||||
"posDeviceHint": "عند الدفع بالبطاقة، يُرسل المبلغ عبر HTTP (POST /pay) إلى الجهاز على الشبكة المحلية.",
|
||||
"posDeviceIp": "عنوان IP لجهاز نقطة البيع",
|
||||
"detect": "كشف تلقائي",
|
||||
"detecting": "جارٍ فحص الشبكة…",
|
||||
"detectNone": "لم يُعثر على أجهزة في الشبكة",
|
||||
"detectOffline": "يجب أن يكون خادم الطباعة متصلاً للكشف التلقائي",
|
||||
"detectHint": "يفحص خادم الطباعة شبكتك المحلية للعثور على الجهاز.",
|
||||
"testSent": "تم إرسال الاختبار إلى الطابعة.",
|
||||
"sent": "تم الإرسال إلى الطابعة.",
|
||||
"noStationItems": "لا توجد أصناف لهذه المحطة في هذا الطلب.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -352,6 +352,11 @@
|
||||
"posDeviceSection": "دستگاه پوز (کارتخوان)",
|
||||
"posDeviceHint": "هنگام پرداخت کارتی، مبلغ به آدرس HTTP دستگاه ارسال میشود (POST /pay).",
|
||||
"posDeviceIp": "آدرس IP دستگاه پوز",
|
||||
"detect": "تشخیص خودکار",
|
||||
"detecting": "در حال جستجوی شبکه…",
|
||||
"detectNone": "دستگاهی در شبکه پیدا نشد",
|
||||
"detectOffline": "برای تشخیص خودکار باید پرینتسرور روشن و متصل باشد",
|
||||
"detectHint": "پرینتسرور شبکه محلی را برای یافتن دستگاه اسکن میکند.",
|
||||
"testSent": "تست به پرینتر ارسال شد.",
|
||||
"sent": "به پرینتر ارسال شد.",
|
||||
"noStationItems": "این سفارش آیتمی برای این ایستگاه ندارد.",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Server, Wifi, WifiOff, Trash2, Plus, Loader2 } from "lucide-react";
|
||||
import { Server, Wifi, WifiOff, Trash2, Plus, Loader2, Radar } from "lucide-react";
|
||||
import { apiGet, apiPatch } from "@/lib/api/client";
|
||||
import {
|
||||
listPrintAgents,
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
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";
|
||||
@@ -324,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
|
||||
@@ -343,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
|
||||
@@ -421,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
|
||||
@@ -447,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,22 @@ export function testPrintDevice(cafeId: string, deviceId: string): Promise<unkno
|
||||
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) =>
|
||||
|
||||
Reference in New Issue
Block a user