# Extension point

> Áp dụng cho `api_version: "2026-01"`.

Extension point (target) là vị trí POS mở sẵn để app gắn vào. Mỗi target quy định: hiển thị ở đâu, kiểu hiển thị nào, module nhận dữ liệu ngữ cảnh gì và phải trả về gì.

## Cách đặt tên

Target gắn với một loại bản ghi của POS có tên dạng `<resource>.<vị trí>`, ví dụ `order.details.action` hay `purchase.index.selection-action`. Mọi resource có cùng sáu vị trí, nên app viết cho đơn hàng dùng y hệt cho phiếu nhập hay phiếu chuyển kho.

| Resource | Màn hình POS | Danh sách | Form chi tiết |
|---|---|---|---|
| `order` | Đơn hàng | Danh sách đơn | Form đơn |
| `product` | Sản phẩm | Danh sách sản phẩm | Modal sản phẩm |
| `purchase` | Nhập hàng | Danh sách phiếu nhập | Modal phiếu nhập |
| `export` | Xuất hàng | Danh sách phiếu xuất | Modal phiếu xuất |
| `warehouse_transfer` | Chuyển kho | Danh sách phiếu chuyển kho | Modal phiếu chuyển kho |

## Danh sách target

| Target | Vị trí trong POS | Kiểu |
|---|---|---|
| `nav.item` | Menu chính, nhóm "Ứng dụng" | Khai báo, không có code |
| `app.page` | Trang riêng `/shop/<shop_id>/apps/<handle>/<path>` | iframe hoặc Worker UI |
| `<resource>.details.action` | Menu thao tác của form chi tiết | headless, modal, fullscreen, navigate |
| `<resource>.index.action` | Menu thao tác trên từng dòng của danh sách | headless, modal, fullscreen, navigate |
| `<resource>.index.selection-action` | Thanh thao tác khi chọn nhiều bản ghi | headless, modal, fullscreen, navigate |
| `<resource>.details.block` | Box trong form chi tiết | Worker UI |
| `<resource>.before_submit` | Trước khi tạo/cập nhật bản ghi trên form chi tiết | hook, headless |
| `<resource>.after_save` | Sau khi tạo/cập nhật bản ghi trên form chi tiết | hook, headless |

`<resource>` là một trong `order`, `product`, `purchase`, `export`, `warehouse_transfer`.

Dự kiến bổ sung: `order.print-action` (mẫu in tuỳ biến), `order.index.column` (cột tuỳ biến trong danh sách đơn), `customer.details.block`.

## Quy ước module

Mọi module là một ES module có `export default` một hàm. Hàm nhận dữ liệu ngữ cảnh (giống `pos.data`) và có thể là `async`.

```ts
// Extension có giao diện (block, action modal/fullscreen, trang Worker UI): tự render vào document.body
export default async function (data) {
  render(<App data={data} />, document.body);
}

// Extension headless và hook: trả về kết quả theo target
export default async function (data) {
  return { message: 'Đã xong' };
}
```

## Kiểu dữ liệu dùng chung

Dữ liệu ngữ cảnh có cùng dạng cho mọi resource: `data.resource` cho biết loại bản ghi, `data.record` là bản ghi đã lưu (`data.records` ở thanh thao tác hàng loạt), `data.draft` là dữ liệu form chi tiết đang hiển thị.

```ts
type Resource = 'order' | 'product' | 'purchase' | 'export' | 'warehouse_transfer';

type RefOf<R extends Resource> = {
  order: OrderRef;
  product: ProductRef;
  purchase: PurchaseRef;
  export: ExportRef;
  warehouse_transfer: WarehouseTransferRef;
}[R];

type DraftOf<R extends Resource> = {
  order: OrderDraft;
  product: ProductDraft;
  purchase: PurchaseDraft;
  export: ExportDraft;
  warehouse_transfer: WarehouseTransferDraft;
}[R];

// Form chi tiết: *.details.action, *.details.block, *.before_submit
type DetailsData<R extends Resource> = {
  resource: R;
  mode: 'create' | 'edit';
  record: RefOf<R> | null;   // null khi bản ghi chưa lưu
  draft: DraftOf<R>;         // gồm cả phần chưa lưu
};

// Một dòng của danh sách: *.index.action
type IndexData<R extends Resource> = {
  resource: R;
  record: RefOf<R>;
};

// Thanh thao tác hàng loạt: *.index.selection-action
type SelectionData<R extends Resource> = {
  resource: R;
  records: RefOf<R>[];       // tối đa 500 bản ghi
};
```

Ví dụ `pos.data` trong `purchase.details.action`:

```json
{
  "resource": "purchase",
  "mode": "edit",
  "record": { "id": "7c1d…", "display_id": 318, "custom_id": null, "status": 0, "warehouse_id": "a52f…", "updated_at": "2026-10-06T02:45:00" },
  "draft": { "status": 0, "warehouse_id": "a52f…", "supplier_products_id": "e09b…", "tags": [], "note": null, "items": [{ "variation_id": "41aa…", "quantity": 20, "imported_price": 1850000 }], "total_price": 37000000 }
}
```

### Mã bản ghi

| Resource | Mã dùng khi gọi Open API | Query do `navigate` thêm vào |
|---|---|---|
| `order` | `record.system_id` | `order_id`, `order_ids` |
| `product` | `record.id` | `product_id`, `product_ids` |
| `purchase` | `record.id` | `purchase_id`, `purchase_ids` |
| `export` | `record.id` | `export_id`, `export_ids` |
| `warehouse_transfer` | `record.id` | `warehouse_transfer_id`, `warehouse_transfer_ids` |

### Kiểu dữ liệu theo resource

```ts
type OrderRef = {
  id: string | number;      // mã đơn hiển thị (mã tuỳ chỉnh nếu shop dùng, ngược lại là mã hệ thống)
  system_id: number;        // mã đơn hệ thống, như trường system_id của đơn trong Open API: /shops/{shop_id}/orders/{system_id}
  status: number;
  updated_at: string;       // ISO 8601
};

type OrderDraft = {
  status: number;
  customer_id: string | null;
  bill_full_name: string | null;
  bill_phone_number: string | null;
  warehouse_id: string | null;
  tags: (number | string)[];
  note: string | null;
  items: {
    variation_id: string;
    product_id: string;
    quantity: number;
    price: number;          // đơn giá sau giảm giá dòng
  }[];
  total_price: number;
};

type ProductRef = {
  id: string;               // UUID: /shops/{shop_id}/products/{id}
  display_id: number;
  custom_id: string | null; // mã sản phẩm
  name: string;
  is_hide: boolean;
  updated_at: string;
};

type ProductDraft = {
  name: string;
  custom_id: string | null;
  category_ids: number[];
  brand_id: number | null;
  tags: (number | string)[];
  variations: {
    id: string | null;      // null với mẫu mã chưa lưu
    custom_id: string | null;
    barcode: string | null;
    retail_price: number;
  }[];
};

type PurchaseRef = {
  id: string;               // UUID: /shops/{shop_id}/purchases/{id}
  display_id: number;
  custom_id: string | null;
  status: number;
  warehouse_id: string;
  updated_at: string;
};

type PurchaseDraft = {
  status: number;
  warehouse_id: string | null;
  supplier_products_id: string | null;   // nhà cung cấp
  tags: (number | string)[];
  note: string | null;
  items: {
    variation_id: string;
    quantity: number;
    imported_price: number;
  }[];
  total_price: number;
};

type ExportRef = {
  id: string;               // UUID: /shops/{shop_id}/export/{id}
  display_id: number;
  custom_id: string | null;
  status: number;
  type: number;             // loại phiếu xuất theo Open API (xuất thường, xuất hàng lỗi…)
  warehouse_id: string;
  updated_at: string;
};

type ExportDraft = {
  status: number;
  type: number;
  warehouse_id: string | null;
  tags: (number | string)[];
  note: string | null;
  export_items: {
    variation_id: string;
    quantity: number;
  }[];
};

type WarehouseTransferRef = {
  id: string;               // UUID: /shops/{shop_id}/transfers/{id}
  display_id: number;
  custom_id: string | null;
  status: number;
  from_warehouse_id: string;
  to_warehouse_id: string;
  updated_status_at: string;
};

type WarehouseTransferDraft = {
  status: number;
  from_warehouse_id: string | null;
  to_warehouse_id: string | null;
  note: string | null;
  items: {
    variation_id: string;
    quantity: number;
  }[];
};
```

- Tên trường và mã trạng thái theo Open API. Trường người dùng không được xem (ví dụ giá nhập khi không có quyền tương ứng) bị bỏ ra; trường nằm trong `access.hidden_order_fields` bị bỏ khỏi `OrderDraft` (giá trị thành `undefined`).
- Dữ liệu ngữ cảnh chỉ gồm các trường tối thiểu. Cần thêm thì gọi Open API bằng `pos.api` (xem [Runtime API](/developers/apps/runtime-api)).
- Nếu bản ghi không thoả giới hạn `access` của app (kho, danh mục sản phẩm, nguồn đơn…), POS không hiển thị extension cho bản ghi đó.

## nav.item

Thêm mục vào menu chính, mở một trang `app.page`.

| Manifest | Mô tả |
|---|---|
| `label` | Nhãn trên menu |
| `page` | `id` của extension `app.page` |

Không có module. Mục chỉ hiện với người được thấy app (mặc định mọi thành viên của shop, xem [Vòng đời app](/developers/apps/overview#vong-doi-app)).


## app.page

Trang riêng của app.

| Manifest | Mô tả |
|---|---|
| `title` | Tiêu đề trang |
| `render` | `iframe`: SPA tự do; `worker`: Worker UI, dùng element `pos-*` |
| `entry` | File HTML trong gói (khi `iframe`) |
| `url` | Trang tự host trên máy chủ của bạn (khi `iframe`, thay cho `entry`) |
| `module` | Module (khi `worker`) |

Dữ liệu ngữ cảnh:

```ts
type PageData = {
  path: string;                     // phần sau /apps/<handle>, ví dụ "/returns"
  query: Record<string, string>;    // query string của URL POS
};
```

- **Trang iframe:** URL đồng bộ hai chiều; app đổi route bên trong thì URL của POS đổi theo, nên tải lại trang, nút quay lại và chia sẻ link đều đúng. Trang kết nối với POS qua SDK, xem [Runtime API › Trang iframe](/developers/apps/runtime-api#trang-iframe).
- **Trang Worker UI:** worker không có history. Đổi đường dẫn bằng `pos.navigate({ page, path, query })`; POS cập nhật URL rồi phát `data-change` với `path`, `query` mới, không tạo lại worker. Nút quay lại của trình duyệt cũng phát `data-change`.

## `*.details.action`

Thêm mục vào menu thao tác của form chi tiết. Các mục của app nằm trong nhóm "Ứng dụng" ở cuối menu, theo thứ tự khai báo.

| Resource | Menu |
|---|---|
| `order` | Menu thao tác mở từ nút Lưu của form đơn |
| `purchase`, `export`, `warehouse_transfer` | Menu trạng thái ở chân modal chi tiết |
| `product` | Nút "Ứng dụng" ở đầu modal sản phẩm, chỉ hiện khi có app thêm action vào đây |

Dữ liệu ngữ cảnh: `DetailsData<R>`.

Với đơn hàng, mục của app không hiện khi menu đang ở chế độ chỉ in hoặc bị ẩn thao tác.

### Kiểu action

| `presentation` | Khi bấm | Module trả về |
|---|---|---|
| `headless` | Chạy module trong worker, mục menu hiện trạng thái đang chạy | `{ message?: string, refresh?: boolean }` hoặc không trả gì |
| `modal` | Mở modal kích thước vừa, module render nội dung | Không dùng; gọi `pos.close()` để đóng |
| `fullscreen` | Mở modal toàn màn hình, đóng lại thì về đúng màn hình cũ | Như `modal` |
| `navigate` | Chuyển sang trang `page` với `path`, kèm mã bản ghi trong query (xem [Mã bản ghi](#ma-ban-ghi)) | Không có module |

Với `headless`:

- Trả về `message` thì POS hiện toast thành công với nội dung đó.
- Ném lỗi (`throw new Error('...')`) thì POS hiện toast lỗi với `error.message`.
- `refresh` mặc định `true`: POS tải lại bản ghi sau khi chạy xong.
- Thời gian chạy tối đa 30 giây; quá thời gian thì worker bị dừng và báo lỗi.

Các video dưới đây dùng đơn hàng; resource khác hoạt động giống hệt.




### should_render

Module `should_render` nhận cùng dữ liệu ngữ cảnh và trả về `boolean`.

- POS tính trước khi bản ghi vừa tải hoặc thay đổi, lưu kết quả theo mã bản ghi và thời điểm cập nhật (`updated_status_at` với phiếu chuyển kho).
- Timeout 300 ms; quá thời gian hoặc lỗi thì **ẩn** mục.
- Ưu tiên dùng `conditions` trong manifest; chỉ dùng `should_render` khi điều kiện phức tạp.

## `*.index.action`

Như `*.details.action` nhưng nằm trên từng dòng của danh sách.

| Resource | Menu |
|---|---|
| `order`, `purchase`, `export`, `warehouse_transfer` | Menu trạng thái trên từng dòng |
| `product` | Menu "⋯" ở cuối mỗi dòng, chỉ hiện khi có app thêm action vào đây |

Dữ liệu ngữ cảnh: `IndexData<R>`.


## `*.index.selection-action`

Mục trên thanh thao tác khi chọn nhiều bản ghi trong danh sách.

Dữ liệu ngữ cảnh: `SelectionData<R>`. Với `navigate`, query kèm theo đủ các mã, ví dụ `purchase_ids=<id>,<id>,...`.


## `*.details.block`

Box nằm trong form chi tiết.

| Resource | Vị trí |
|---|---|
| `order` | Ngay sau mục Ghi chú |
| `product`, `purchase`, `export`, `warehouse_transfer` | Cuối modal chi tiết, phía trên các nút ở chân modal |

- Dữ liệu ngữ cảnh: `DetailsData<R>` (như `*.details.action`).
- Khi người dùng sửa form, POS cập nhật `pos.data` và phát sự kiện `data-change` (xem [Runtime API › Sự kiện](/developers/apps/runtime-api#su-kien)).
- Box có chiều rộng bằng cột chứa nó, chiều cao tự co giãn theo nội dung.
- Box không chặn hiển thị form: trong lúc worker khởi động, POS hiện khung chờ.


## `*.after_save`

Chạy sau khi tạo hoặc cập nhật bản ghi thành công **trên form chi tiết của POS web**.

```ts
type AfterSaveInput<R extends Resource> = {
  resource: R;
  mode: 'create' | 'edit';
  record: RefOf<R>;
};
```

- Không chặn người dùng; giá trị trả về bị bỏ qua.
- Lỗi chỉ ghi vào nhật ký lỗi của app, không hiện cho người dùng.


## `*.before_submit`

Chạy trước khi tạo hoặc cập nhật bản ghi **trên form chi tiết của POS web**.

```ts
type BeforeSubmitInput<R extends Resource> = DetailsData<R>;

type BeforeSubmitResult =
  | { ok: true }
  | { ok: false; message: string };   // message tối đa 200 ký tự, hiện cho người dùng
```

- Nhiều app chạy song song. Chỉ cần một hook trả `ok: false` là bản ghi không được lưu; POS hiện tất cả `message`.
- Hết `timeout_ms` hoặc module lỗi: xử lý theo `on_error` (`allow` cho lưu, `block` chặn lưu).
- Phiên bản này chỉ cho **chặn**, không cho sửa dữ liệu bản ghi.


### Khi nào hook chạy

`*.before_submit` và `*.after_save` chỉ chạy khi người dùng bấm lưu trên form chi tiết của POS web. Hook **không** chạy với thay đổi từ nơi khác: menu trạng thái trên dòng của danh sách, cập nhật hàng loạt, nhập Excel, Open API, app mobile hay đồng bộ sàn. Quy tắc bắt buộc đúng mọi lúc cần làm ở phía server.
