# Manifest (pos-app.json)

`pos-app.json` nằm ở thư mục gốc của dự án. POS đọc manifest khi upload để biết app cần quyền gì, được gọi domain nào và gắn vào những vị trí nào.

## Ví dụ đầy đủ

```json
{
  "handle": "doi-soat-cong-no",
  "name": "Đối soát công nợ",
  "description": "Kiểm tra công nợ khi lên đơn và đối soát cuối ngày.",
  "version": "1.3.0",
  "api_version": "2026-01",
  "access": {
    "methods": ["GET", "POST", "PUT"],
    "warehouse_ids": null,
    "hidden_order_fields": ["bill_phone_number"]
  },
  "network": {
    "allowed_origins": ["https://erp.khachhang.vn"]
  },
  "extensions": [
    { "target": "nav.item", "id": "nav", "label": "Đối soát", "page": "home" },
    { "target": "app.page", "id": "home", "title": "Đối soát công nợ", "render": "iframe", "entry": "page/index.html" },
    {
      "target": "order.details.action",
      "id": "push-erp",
      "label": "Đẩy sang ERP",
      "presentation": "headless",
      "module": "extensions/push-erp.ts",
      "conditions": { "status": [1, 2] }
    },
    {
      "target": "order.details.action",
      "id": "warranty",
      "label": "Tạo phiếu bảo hành",
      "presentation": "modal",
      "title": "Phiếu bảo hành",
      "module": "extensions/warranty-modal.tsx",
      "should_render": "extensions/warranty-should-render.ts"
    },
    { "target": "order.details.block", "id": "debt", "module": "extensions/debt-block.tsx" },
    {
      "target": "purchase.index.action",
      "id": "push-purchase-erp",
      "label": "Đẩy sang ERP",
      "presentation": "headless",
      "module": "extensions/push-purchase-erp.ts",
      "conditions": { "status": [1] }
    },
    {
      "target": "order.before_submit",
      "id": "check-debt",
      "module": "extensions/check-debt.ts",
      "timeout_ms": 2000,
      "on_error": "allow"
    }
  ]
}
```

## Trường cấp gốc

| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
| `handle` | string | Có | Định danh app trong shop, khớp `^[a-z0-9][a-z0-9-]{1,39}$`. Dùng trong URL trang của app. Đổi `handle` thì lần upload sau tạo một app mới, tách khỏi app cũ. |
| `name` | string | Có | Tên hiển thị, tối đa 60 ký tự. |
| `description` | string | Không | Mô tả ngắn, tối đa 300 ký tự. Hiển thị cho admin khi kích hoạt. |
| `version` | string | Có | Semver, ví dụ `1.3.0`. Mỗi lần upload phải dùng version chưa tồn tại. |
| `api_version` | string | Có | Phiên bản hợp đồng của POS (`YYYY-MM`), quyết định danh sách target, component và schema dữ liệu ngữ cảnh. |
| `access` | object | Không | Giới hạn dữ liệu app được dùng. Xem [access](#access). |
| `network` | object | Không | Domain bên ngoài app được gọi. Xem [network](#network). |
| `extensions` | array | Có | Danh sách extension, tối đa 30. Xem [extensions](#extensions). |

## access

Giới hạn **thêm** trên quyền của người đang dùng. App token không bao giờ có quyền vượt quá người dùng; `access` chỉ thu hẹp thêm.

| Trường | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `methods` | string[] | `["GET"]` | HTTP method được dùng: `GET`, `POST`, `PUT`, `DELETE`. Không khai thì app **chỉ đọc**. |
| `warehouse_ids` | uuid[] | không giới hạn | Chỉ thao tác trên các kho trong danh sách. Áp dụng cho đơn hàng, phiếu nhập, phiếu xuất, phiếu chuyển kho và tồn kho theo kho. |
| `order_sources` | string[] | không giới hạn | Chỉ thấy đơn có nguồn trong danh sách. |
| `hidden_order_sources` | string[] | không ẩn | Ẩn đơn có nguồn trong danh sách. |
| `order_statuses` | int[] | không giới hạn | Chỉ thấy đơn có trạng thái trong danh sách. |
| `order_since` | datetime (ISO 8601) | không giới hạn | Chỉ thấy đơn tạo từ thời điểm này. |
| `hidden_order_fields` | string[] | không ẩn | Bỏ các trường này khỏi dữ liệu đơn trả về cho app, kể cả dữ liệu ngữ cảnh. |
| `product_categories` | int[] | không giới hạn | Chỉ thấy sản phẩm thuộc các danh mục này. |
| `product_brands` | int[] | không giới hạn | Chỉ thấy sản phẩm thuộc các thương hiệu này. |

Giá trị `null` hoặc không khai nghĩa là không giới hạn thêm (trừ `methods`). Giới hạn hiệu lực là `access` của version đang kích hoạt.

## network

| Trường | Kiểu | Mô tả |
|---|---|---|
| `allowed_origins` | string[] | Origin `https://` app được gọi bằng `fetch`, tối đa 10, không dùng wildcard. Open API của POS luôn được phép, không cần khai. Cũng áp dụng cho ảnh của `pos-image`. |

Request tới origin không có trong danh sách bị trình duyệt chặn. Server bên ngoài phải bật CORS cho domain sandbox của app (xem [Truy cập dữ liệu](/developers/apps/data-access#goi-he-thong-ben-ngoai)).

## extensions

### Trường chung

| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
| `target` | string | Có | Vị trí gắn extension. Xem [Extension point](/developers/apps/targets). |
| `id` | string | Có | Duy nhất trong app, khớp `^[a-z0-9-]{1,40}$`. |
| `module` | string | Tuỳ target | Đường dẫn file nguồn (`.ts`, `.tsx`, `.js`, `.jsx`). CLI build thành một ES module độc lập. Bắt buộc với target Worker UI và headless. |

### Trường cho action

Áp dụng cho `*.details.action`, `*.index.action` và `*.index.selection-action` của mọi resource.

| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
| `label` | string | Có | Nhãn mục menu, tối đa 40 ký tự. |
| `presentation` | string | Có | `headless`, `modal`, `fullscreen` hoặc `navigate`. Xem [kiểu action](/developers/apps/targets#kieu-action). |
| `title` | string | Không | Tiêu đề modal (`modal`, `fullscreen`). Mặc định bằng `label`. |
| `page` | string | Khi `navigate` | `id` của extension `app.page` cần mở. |
| `path` | string | Không | Đường dẫn bên trong trang khi `navigate`, ví dụ `/returns`. |
| `conditions` | object | Không | Điều kiện hiện mục menu, POS tự kiểm tra. Xem [conditions](#conditions). |
| `should_render` | string | Không | Module trả về `true`/`false` để quyết định có hiện mục hay không. Dùng khi `conditions` không đủ. |

### Trường cho block

Áp dụng cho `*.details.block`.

| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
| `conditions` | object | Không | Điều kiện hiện block. |
| `should_render` | string | Không | Như action. |

### Trường cho trang và menu

| Target | Trường | Mô tả |
|---|---|---|
| `app.page` | `title` (bắt buộc) | Tiêu đề trang. |
| `app.page` | `render` (bắt buộc) | `iframe` hoặc `worker` (trang Worker UI dự kiến ở giai đoạn 2). |
| `app.page` | `entry` | File HTML của trang trong gói, khi `render: "iframe"`. Khai `entry` **hoặc** `url`. |
| `app.page` | `url` | Trang tự host: địa chỉ `https` trên máy chủ của bạn, POS nhúng thẳng thay cho file trong gói. Không chứa tài khoản, query, `#`; không dùng domain của Pancake, `localhost` hay địa chỉ IP. Đổi `url` phải deploy version mới. Xem [Trang tự host](/developers/apps/runtime-api#trang-tu-host). |
| `app.page` | `module` | Bắt buộc khi `render: "worker"`. |
| `nav.item` | `label` (bắt buộc) | Nhãn trên menu POS, tối đa 30 ký tự. |
| `nav.item` | `page` (bắt buộc) | `id` của extension `app.page`. |

### Trường cho hook

Áp dụng cho `*.before_submit` và `*.after_save`.

| Trường | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `timeout_ms` | int | `2000` | Chỉ cho `before_submit`, tối đa `5000`. |
| `on_error` | string | `allow` | Chỉ cho `before_submit`: `allow` cho lưu tiếp khi hook lỗi/timeout, `block` chặn lưu. |

## conditions

Các khoá kết hợp theo **AND**; giá trị mảng trong một khoá kết hợp theo **OR**. POS kiểm tra ngay khi hiển thị nên không làm chậm menu.

| Khoá | Kiểu | Resource | Đúng khi |
|---|---|---|---|
| `mode` | string[] | Tất cả (target trên form chi tiết) | `create` (bản ghi mới chưa lưu) hoặc `edit` (bản ghi đã có). |
| `status` | int[] | `order`, `purchase`, `export`, `warehouse_transfer` | Trạng thái bản ghi nằm trong danh sách (mã trạng thái theo Open API). |
| `warehouse_ids` | uuid[] | `order`, `purchase`, `export`, `warehouse_transfer` | Kho của bản ghi nằm trong danh sách; với phiếu chuyển kho là kho đi hoặc kho đến. |
| `tags_any` | (int \| string)[] | `order`, `product`, `purchase`, `export` | Bản ghi có ít nhất một thẻ trong danh sách (ID thẻ). |
| `type` | int[] | `export` | Loại phiếu xuất nằm trong danh sách. |
| `category_ids` | int[] | `product` | Sản phẩm thuộc ít nhất một danh mục trong danh sách. |
| `order_sources` | string[] | `order` | Nguồn đơn nằm trong danh sách. |
| `has_customer` | boolean | `order` | Đơn đã/chưa gắn khách hàng. |

Dùng khoá không áp dụng cho resource của target là lỗi kiểm tra (`invalid_condition`).

Ví dụ: chỉ hiện cho đơn đã có, trạng thái 1 hoặc 2, có thẻ VIP (ID 7):

```json
{ "mode": ["edit"], "status": [1, 2], "tags_any": [7] }
```

Ví dụ: chỉ hiện cho phiếu nhập đã nhập hàng (trạng thái 1) vào một kho cụ thể:

```json
{ "status": [1], "warehouse_ids": ["a52f…"] }
```

## Lỗi kiểm tra manifest

| Mã | Nguyên nhân |
|---|---|
| `invalid_handle` | `handle` sai định dạng. |
| `invalid_field` | Giá trị của trường không hợp lệ, ví dụ quá độ dài cho phép, khoá lạ trong `access` hoặc `access.methods` sai. |
| `duplicate_version` | `version` đã tồn tại. |
| `unsupported_api_version` | `api_version` không còn hỗ trợ hoặc không tồn tại. |
| `unknown_target` | `target` không có trong `api_version` đã khai. |
| `missing_field` | Thiếu trường bắt buộc theo target. |
| `module_not_found` | Không tìm thấy file `module`/`entry`/`should_render`. |
| `bundle_too_large` | Module hoặc bundle vượt giới hạn kích thước. |
| `storage_quota_exceeded` | Tổng dung lượng app của shop sẽ vượt 500 MB. |
| `invalid_bundle` | File upload không phải gói `.zip` hợp lệ, hoặc có đường dẫn file không an toàn. |
| `upload_failed` | Không lưu được bundle; thử upload lại. |
| `invalid_origin` | `allowed_origins` không phải `https://` hoặc có wildcard. |
| `invalid_url` | `url` của `app.page` không phải `https` theo tên miền, có tài khoản, query, `#`, hoặc thuộc domain của Pancake. |
| `invalid_condition` | Khoá trong `conditions` không áp dụng cho resource của target. |
