Tên trường theo Open API.
App mẫu "Đồng bộ ERP" gồm:
- Block trong form đơn hiện trạng thái đồng bộ ERP của đơn.
- Action headless "Đẩy sang ERP".
- Action modal "Phiếu bảo hành" có form nhập.
- Hook chặn lưu đơn trên 20 triệu khi thiếu số điện thoại.
- Trang iframe "Đối soát ERP" liệt kê đơn.
- Action hàng loạt đẩy các phiếu chuyển kho đang chọn sang ERP, cho thấy resource khác dùng y hệt.
ERP trong ví dụ là https://erp.example.com, do shop tự quản lý và đã bật CORS cho domain sandbox của app.
Manifest
{
"handle": "erp-sync",
"name": "Đồng bộ ERP",
"version": "1.0.0",
"api_version": "2026-01",
"access": { "methods": ["GET", "PUT"] },
"network": { "allowed_origins": ["https://erp.example.com"] },
"extensions": [
{ "target": "nav.item", "id": "nav", "label": "Đối soát ERP", "page": "reconcile" },
{ "target": "app.page", "id": "reconcile", "title": "Đối soát ERP", "render": "iframe", "entry": "page/index.html" },
{
"target": "order.details.block",
"id": "erp-status",
"module": "extensions/erp-status.tsx",
"conditions": { "mode": ["edit"] }
},
{
"target": "order.details.action",
"id": "push-erp",
"label": "Đẩy sang ERP",
"presentation": "headless",
"module": "extensions/push-erp.ts",
"conditions": { "mode": ["edit"] }
},
{
"target": "order.details.action",
"id": "warranty",
"label": "Phiếu bảo hành",
"presentation": "modal",
"module": "extensions/warranty.tsx",
"conditions": { "mode": ["edit"] }
},
{
"target": "order.before_submit",
"id": "require-phone",
"module": "extensions/require-phone.ts",
"timeout_ms": 1000,
"on_error": "allow"
},
{
"target": "warehouse_transfer.index.selection-action",
"id": "push-transfers",
"label": "Đẩy sang ERP",
"presentation": "headless",
"module": "extensions/push-transfers.ts"
}
]
}1. Block trạng thái ERP
extensions/erp-status.tsx
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';
type Status = { synced: boolean; erp_code?: string; synced_at?: string };
function ErpStatus({ systemId }: { systemId: number }) {
const [status, setStatus] = useState<Status | null>(null);
const [error, setError] = useState<string | null>(null);
const load = async () => {
setError(null);
try {
const res = await fetch(`https://erp.example.com/api/orders/${systemId}`);
if (!res.ok) throw new Error(`ERP trả về ${res.status}`);
setStatus(await res.json());
} catch (e) {
setError(e.message);
}
};
useEffect(() => { load(); }, [systemId]);
return (
<pos-section heading="ERP">
<pos-button slot="actions" variant="tertiary" onClick={load}>Tải lại</pos-button>
{error && <pos-banner tone="critical">{error}</pos-banner>}
{!error && !status && <pos-spinner size="small" />}
{status && (
<pos-stack direction="horizontal" gap="small" align="center">
<pos-badge tone={status.synced ? 'success' : 'warning'}>
{status.synced ? 'Đã đồng bộ' : 'Chưa đồng bộ'}
</pos-badge>
{status.erp_code && <pos-text tone="subdued">Mã ERP: {status.erp_code}</pos-text>}
</pos-stack>
)}
</pos-section>
);
}
export default async function (data) {
render(<ErpStatus systemId={data.record.system_id} />, document.body);
}2. Action headless "Đẩy sang ERP"
extensions/push-erp.ts
export default async function (data) {
const shopId = pos.shop.id;
const systemId = data.record.system_id;
const { data: order } = await pos.api.get(`/shops/${shopId}/orders/${systemId}`);
const res = await fetch('https://erp.example.com/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pos_order_id: systemId,
customer_name: order.bill_full_name,
total: order.total_price,
items: order.items,
}),
});
if (!res.ok) throw new Error(`ERP từ chối đơn (${res.status})`);
const { erp_code } = await res.json();
// Ghi mã ERP vào ghi chú đơn; xem tài liệu Open API về các trường cập nhật được.
const note = [order.note, `ERP: ${erp_code}`].filter(Boolean).join('\n');
await pos.api.put(`/shops/${shopId}/orders/${systemId}`, { note });
return { message: `Đã đẩy sang ERP: ${erp_code}`, refresh: true };
}Lỗi ném ra (throw) được POS hiện thành toast lỗi.
3. Action modal "Phiếu bảo hành"
extensions/warranty.tsx
import { render } from 'preact';
import { useState } from 'preact/hooks';
function WarrantyForm({ systemId }: { systemId: number }) {
const [serial, setSerial] = useState('');
const [expiresOn, setExpiresOn] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!serial) return setError('Nhập số serial');
setSaving(true);
setError(null);
try {
const res = await fetch('https://erp.example.com/api/warranties', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pos_order_id: systemId, serial, expires_on: expiresOn }),
});
if (!res.ok) throw new Error(`ERP trả về ${res.status}`);
pos.toast.show('Đã tạo phiếu bảo hành');
pos.close();
} catch (e) {
setError(e.message);
} finally {
setSaving(false);
}
};
return (
<pos-stack>
{error && <pos-banner tone="critical">{error}</pos-banner>}
<pos-text-field label="Số serial" required value={serial} onChange={(e) => setSerial(e.detail)} />
<pos-date-picker label="Hết hạn bảo hành" value={expiresOn} onChange={(e) => setExpiresOn(e.detail)} />
<pos-stack direction="horizontal" justify="end" gap="small">
<pos-button onClick={() => pos.close()}>Huỷ</pos-button>
<pos-button variant="primary" loading={saving} onClick={submit}>Tạo phiếu</pos-button>
</pos-stack>
</pos-stack>
);
}
export default async function (data) {
render(<WarrantyForm systemId={data.record.system_id} />, document.body);
}4. Hook chặn lưu đơn
extensions/require-phone.ts
const LIMIT = 20_000_000;
export default async function ({ draft }) {
if (draft.total_price > LIMIT && !draft.bill_phone_number) {
return { ok: false, message: 'Đơn trên 20 triệu cần có số điện thoại người nhận.' };
}
return { ok: true };
}Nếu shop ẩn bill_phone_number bằng access.hidden_order_fields, trường này luôn là undefined và hook sẽ chặn mọi đơn lớn. Kiểm tra access khi viết hook.
5. Trang iframe "Đối soát ERP"
page/src/main.tsx (React + Vite)
import { createRoot } from 'react-dom/client';
import { useEffect, useState } from 'react';
import { connect } from '@pancake-pos/ui-extensions/page';
const pos = await connect();
function Reconcile() {
const [page, setPage] = useState(1);
const [orders, setOrders] = useState([]);
const [total, setTotal] = useState(0);
useEffect(() => {
const controller = new AbortController();
pos.api
.get(`/shops/${pos.shop.id}/orders`, {
query: { page_number: page, page_size: 20 },
signal: controller.signal,
})
.then((res) => {
setOrders(res.data);
setTotal(res.total_entries);
})
.catch((e) => e.name !== 'AbortError' && pos.toast.show(e.message, { tone: 'error' }));
return () => controller.abort();
}, [page]);
useEffect(() => {
pos.page.setTitle('Đối soát ERP');
pos.page.setPrimaryAction({ label: 'Đồng bộ lại' });
return pos.on('primary-action', () => pos.toast.show('Đang đồng bộ...', { tone: 'info' }));
}, []);
return (
<table>
<thead>
<tr><th>Mã đơn</th><th>Khách hàng</th><th>Tổng tiền</th></tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={o.system_id}>
<td>{o.system_id}</td>
<td>{o.bill_full_name}</td>
<td>{o.total_price}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={3}>
<button disabled={page === 1} onClick={() => setPage(page - 1)}>Trước</button>
<span> Trang {page} / {Math.max(1, Math.ceil(total / 20))} </span>
<button disabled={page * 20 >= total} onClick={() => setPage(page + 1)}>Sau</button>
</td>
</tr>
</tfoot>
</table>
);
}
createRoot(document.getElementById('root')!).render(<Reconcile />);Trang iframe dùng HTML/React tự do; có thể thay bảng trên bằng component của @pancake-pos/ui để giao diện giống POS.
6. Action hàng loạt cho phiếu chuyển kho
extensions/push-transfers.ts
// warehouse_transfer.index.selection-action: records là WarehouseTransferRef[]
export default async function ({ records }) {
const res = await fetch('https://erp.example.com/api/stock-transfers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
records.map((t) => ({
pos_transfer_id: t.id,
code: t.custom_id || String(t.display_id),
from_warehouse_id: t.from_warehouse_id,
to_warehouse_id: t.to_warehouse_id,
status: t.status,
}))
),
});
if (!res.ok) throw new Error(`ERP từ chối phiếu chuyển kho (${res.status})`);
return { message: `Đã đẩy ${records.length} phiếu chuyển kho sang ERP`, refresh: false };
}Module viết giống hệt cho đơn hàng, chỉ khác resource: records chứa WarehouseTransferRef, dùng id khi gọi Open API (/shops/{shop_id}/transfers/{id}). Cần danh sách sản phẩm trong phiếu thì đọc qua Open API.
