Core Docs

ButtonWord

Button xuất file Word (.docx) từ template — placeholder được Core fill data từ OData query trên server.

Source: Core/Components/ButtonWord.cs · ComType: ButtonWord Pipeline backend: FTP.TMS/Controllers/FileUploadController.cs::CreateDocs (gọi qua https://cdn-tms.softek.com.vn/api/FileUpload/CreateDocs)

Pipeline tổng thể

Khi user click ButtonWord, Core thực hiện 3 bước:

1. Lấy SQL/OData query        ─── Component.Query (string hoặc JS function)


2. POST /api/User/ReportDataSet?sys=<Component.System>
   nhận về Dictionary<string, object>[][] (array of arrays)


3. POST cdn-tms.softek.com.vn/api/FileUpload/CreateDocs
   với { Url: <Template>, Data: <dataset>, OutputName: <…>.docx }


   Backend tải template .docx, fill placeholder, trả URL file kết quả


4. CanCache = false → mở file qua Office Online Viewer
   CanCache = true  → trigger browser download

Cấu hình row Component

ComType:    ButtonWord
Label:      Xuất hợp đồng Word
Icon:       fas fa-file-word
Template:   https://cdn-tms.softek.com.vn/uploads/template_contract.docx   # URL public của template
Query:      "?$filter=Id eq {Id}"                                          # OData lấy data
System:     contract                                                       # SQL builder system flag
PlainText:  "{Code}_{Name}"                                                # tên file output (template)
CanCache:   false                                                          # false = preview, true = download trực tiếp

Field DB cấu hình

FieldVai trò
TemplateURL public file .docx template chứa các placeholder. Backend GetByteArrayAsync URL này.
QueryOData/SQL query lấy data. String (?$filter=...) hoặc JS function function(entity, com, formEntity, list){ return "?$filter=..."; }.
PreQueryJS function chạy trước Query để chuẩn bị / lookup dependency. Output truyền vào Query.
SystemString flag để backend ReportDataSet pick đúng SQL builder. Vd "contract", "invoice".
IdFieldFallback dùng làm sys nếu System không set.
PlainTextTemplate tên file output với placeholder {FieldName}. Vd "{Code}_{Name}""INV001_ACME.docx". Empty → fallback <FieldName>.docx.
FieldNameFallback tên file nếu PlainText rỗng.
GroupFormat + Precision (> 0)Distinct selectedRows theo GroupFormat field — tránh duplicate khi 1 group có nhiều row.
CanCachefalse (default) → mở file qua Office Online Viewer trên tab mới. true → download .docx về máy.
EventsJSON {"click": "MethodName"} — method được gọi trước khi load data + tạo Word.

Cấu trúc Data mà server trả về (Dictionary<string, object>[][])

Backend ReportDataSet trả về mảng 2 chiềudata[i][j] với:

IndexÝ nghĩa
data[0]Main values (placeholder text trong paragraph / header / footer). Thường có 1 row: data[0][0].
data[1]Rows cho table 1 — mỗi data[1][j] = 1 row của table 1.
data[2]Rows cho table 2 — tương tự.
data[N]Table thứ N trong template (theo thứ tự xuất hiện).

Ví dụ:

[
  // data[0] - main paragraph values (1 row dict)
  [
    { "ContractNo": "HD001", "CustomerName": "ACME Co.", "Date": "15/03/2026", "Total": "12500000" }
  ],

  // data[1] - rows cho table đầu tiên trong template
  [
    { "stt": 1, "Product": "Sản phẩm A", "Qty": 10, "Price": 100000 },
    { "stt": 2, "Product": "Sản phẩm B", "Qty":  5, "Price": 200000 }
  ],

  // data[2] - rows cho table thứ 2 (nếu có)
  [
    { "Note": "Ghi chú 1" },
    { "Note": "Ghi chú 2" }
  ]
]

Cú pháp placeholder trong template .docx

Số hợp đồng: ${ContractNo}
Khách hàng: ${CustomerName}
Ngày: ${Date}

→ Replace bằng data[0][0].ContractNo, data[0][0].CustomerName, …

#{expr} — placeholder cho cell trong table

| STT | Sản phẩm    | SL       | Đơn giá       |
| #{stt} | #{Product} | #{Qty} | #{Price:n0} |

→ Row template được clone cho mỗi item trong data[<tableIndex>] và fill data từng cell.

Format suffix sau : (cho cả ${...}#{...})

FormatTác dụng
:n0Số không thập phân, có dấu phân tách: 1,234,567
:n2Số 2 chữ số thập phân: 1,234,567.89
:n4Số 4 chữ số thập phân.
:viĐọc số tiền tiếng Việt: 12500000"Mười hai triệu năm trăm nghìn đồng".
:enĐọc số tiền tiếng Anh.
:ngayDate dd/MM/yyyy → lấy ngày: 15
:thangDate dd/MM/yyyy → lấy tháng: 03
:namDate dd/MM/yyyy → lấy năm: 2026

Ví dụ:

Tổng tiền: ${Total:n0} VND
Bằng chữ: ${Total:vi}
Ngày ${Date:ngay} tháng ${Date:thang} năm ${Date:nam}

Biểu thức số học trong placeholder

${...} chấp nhận cả biểu thức math với các field là số:

Tổng cộng: ${Qty * Price:n0}
Còn lại: ${Total - Paid:n0}

(Backend dùng DataTable.Compute evaluate.)

Conditional block — @if / @else / @endif

Trong paragraph (mỗi block 1 paragraph riêng):

@if({Status} == {APPROVED})
Đơn hàng đã được duyệt.
@else
Đơn hàng chưa duyệt — đang chờ xử lý.
@endif

→ Backend giữ block phù hợp, xóa block còn lại.

Conditional ẩn page 2 — <page2>condition</page2>

Đặt trong paragraph trước trang 2:

<page2>data[1].length == 0</page2>

→ Nếu condition true (table rows trống) → backend xóa toàn bộ trang 2 (paragraph + tables sau cho đến hết file). Trong tables ở page 2 phải có 1 cell chứa #{p2} để backend nhận biết.

Font size dynamic — <size>expression</size>

<size>this[ContractNo].l > 20 ? 9 : 11</size>

→ Nếu length của ContractNo > 20 ký tự → font size 9; ngược lại 11. Hữu ích để text dài tự co lại không tràn dòng.

Ví dụ template Word đầy đủ

HỢP ĐỒNG SỐ ${ContractNo}

Hôm nay, ngày ${Date:ngay} tháng ${Date:thang} năm ${Date:nam}, chúng tôi gồm:

Bên A: ${CompanyA}
Bên B: ${CustomerName}, địa chỉ: ${CustomerAddress}

@if({HasVAT} == {true})
Đã bao gồm thuế VAT 10%.
@else
Chưa bao gồm thuế VAT.
@endif

[Bảng hàng hóa - 1 dòng template duy nhất, sẽ clone cho mỗi item]
| STT     | Tên hàng    | SL      | Đơn giá       | Thành tiền             |
| #{stt}  | #{Product}  | #{Qty}  | #{Price:n0}   | #{Qty * Price:n0}      |

Tổng cộng: ${Total:n0} VND
Bằng chữ: ${Total:vi}

Trên class C# (TMS.UI)

Wire qua Events:

# Row Component
Events: '{"click": "BeforeExportContract"}'
public class ContractDetailBL : PopupEditor
{
    public ContractDetailBL() : base(nameof(Contract)) { }

    // Hook trước khi data được load và Word được tạo
    public async Task BeforeExportContract(object entity, object guiInfo)
    {
        // vd: refresh entity, validate trước export, lưu draft, ...
        if (!await this.IsFormValid()) return;
    }
}

Method Click chạy trước LoadData() — không có hook “AfterExport”. Muốn xử lý sau khi file tạo xong, dùng MultipleButtonPdf (có AfterDownload) thay vì ButtonWord.

Bẫy hay gặp

  • Template URL không public → backend GetByteArrayAsync fail → return null. Đảm bảo URL truy cập được không cần auth.
  • Query không match SQL builder → server trả 500 hoặc empty. Check tên System flag đúng chưa.
  • Placeholder không đúng prefix — paragraph dùng ${...}, table cell dùng #{...}. Nhầm prefix sẽ không replace.
  • Table không có row template với placeholder #{...} → backend skip table đó.
  • Format suffix không hỗ trợ (vd ${value:abc}) → fallback dùng raw value.
  • Field tên trùng từ khoá math (COUNT, SUM) → DataTable.Compute hiểu lệch. Đặt tên field rõ.
  • Date format trong data phải là "dd/MM/yyyy" chuẩn (cho :ngay/:thang/:nam work).
  • Output file lưu trong wwwroot/download/ của FTP service, có tên <output>_<guid>.docx để tránh trùng. URL trả về dạng <scheme>://<host>/download/<filename>.docx.

Tip

  • Preview vs download: CanCache = false mở Office Online Viewer (cần internet) — preview nhanh không cần Word client. CanCache = true download hẳn file .docx về máy.
  • Nhiều row được chọn trong grid + GroupFormat + Precision > 0 → Core distinct theo GroupFormat field, mỗi nhóm 1 file Word riêng.
  • Test placeholder: tạo template thử với chỉ vài ${field}, click button → mở Word kiểm tra. Xác minh xong mới thêm logic phức tạp (@if, table, :vi).
  • Cache template trên CDN — Word template ít thay đổi, đặt URL CDN có cache header.

📚 Bộ ví dụ thực tế

Ví dụ 1 — Hợp đồng đơn giản (1 trang, không bảng)

Use case: in hợp đồng kinh tế từ form chi tiết hợp đồng.

Cấu hình row Component:

ComType:    ButtonWord
Name:       btnExportContract
Label:      Xuất hợp đồng
Icon:       fas fa-file-word
Template:   https://cdn-tms.softek.com.vn/uploads/contract_template.docx
Query:      "?$filter=Id eq {Id}"
System:     contract
PlainText:  "HD_{ContractNo}_{CustomerCode}"
ClassName:  btn btn-primary
CanCache:   false

Template contract_template.docx (placeholder bên trong):

HỢP ĐỒNG KINH TẾ
Số: ${ContractNo}

Hôm nay, ngày ${Date:ngay} tháng ${Date:thang} năm ${Date:nam}

BÊN A: ${CompanyName}
   Địa chỉ: ${CompanyAddress}
   MST: ${CompanyTaxCode}

BÊN B: ${CustomerName}
   Địa chỉ: ${CustomerAddress}
   MST: ${CustomerTaxCode}

Giá trị hợp đồng: ${TotalAmount:n0} VND
Bằng chữ: ${TotalAmount:vi}

Data server trả về (1 row main, không bảng):

[
  [
    {
      "ContractNo": "HD-2026-001",
      "Date": "15/03/2026",
      "CompanyName": "Công ty TNHH Softek",
      "CompanyAddress": "123 Lê Lợi, Q.1, TP.HCM",
      "CompanyTaxCode": "0312345678",
      "CustomerName": "Công ty CP ACME",
      "CustomerAddress": "456 Nguyễn Huệ, Q.1, TP.HCM",
      "CustomerTaxCode": "0398765432",
      "TotalAmount": "125000000"
    }
  ]
]

→ File output: HD_HD-2026-001_ACME.docx. Mở qua Office Online Viewer.


Ví dụ 2 — Hoá đơn VAT có bảng hàng hoá

Use case: in hoá đơn từ tab Invoice, có nhiều dòng hàng + tổng tiền + đọc số.

ComType:    ButtonWord
Name:       btnPrintInvoice
Label:      In hoá đơn
Icon:       fas fa-print
Template:   https://cdn-tms.softek.com.vn/uploads/invoice_vat_template.docx
Query:      "?$expand=Lines($expand=Product),Customer&$filter=Id eq {Id}"
System:     invoice
PlainText:  "HD-{InvoiceNo}"
HotKey:     Ctrl+P
CanCache:   true                    # download file thay vì preview

Template (có 1 bảng):

HOÁ ĐƠN GIÁ TRỊ GIA TĂNG
Số: ${InvoiceNo}     Ngày: ${Date}

Khách hàng: ${CustomerName}
MST: ${CustomerTaxCode}

────────────────────────────────────────────────────
[Bảng — chỉ 1 dòng template, sẽ clone cho mỗi item]
| STT     | Tên hàng     | ĐVT       | SL      | Đơn giá       | Thành tiền             |
| #{stt}  | #{Product}   | #{Unit}   | #{Qty}  | #{Price:n0}   | #{Qty * Price:n0}      |
────────────────────────────────────────────────────

Tổng tiền hàng:  ${SubTotal:n0} VND
Thuế VAT (10%):  ${VAT:n0} VND
TỔNG CỘNG:       ${Total:n0} VND
Bằng chữ: ${Total:vi}

Data server trả về (1 row main + 1 table):

[
  [
    {
      "InvoiceNo": "HD000123",
      "Date": "20/03/2026",
      "CustomerName": "ACME Co.",
      "CustomerTaxCode": "0398765432",
      "SubTotal": "10000000",
      "VAT": "1000000",
      "Total": "11000000"
    }
  ],
  [
    { "stt": 1, "Product": "Sản phẩm A", "Unit": "Cái", "Qty": 10, "Price": 500000 },
    { "stt": 2, "Product": "Sản phẩm B", "Unit": "Hộp", "Qty":  5, "Price": 800000 },
    { "stt": 3, "Product": "Sản phẩm C", "Unit": "Bộ",  "Qty":  2, "Price": 500000 }
  ]
]

→ Bảng được clone 3 dòng (3 sản phẩm), formula Qty * Price evaluate tự động. File HD-HD000123.docx download trực tiếp.


Ví dụ 3 — Phiếu xuất kho có conditional VAT

Use case: phiếu có hoặc không có thuế tuỳ loại khách hàng.

ComType:    ButtonWord
Name:       btnExportShipping
Label:      Xuất phiếu giao hàng
Icon:       fas fa-truck-loading
Template:   https://cdn-tms.softek.com.vn/uploads/shipping_template.docx
Query:      "?$expand=Items&$filter=Id eq {Id}"
System:     shipping
PlainText:  "PXK_{Code}_{Date:yyyyMMdd}"

Template (có conditional @if):

PHIẾU XUẤT KHO
Mã: ${Code}     Ngày: ${Date}

Người nhận: ${ReceiverName}

@if({HasVAT} == {true})
Đã bao gồm thuế VAT 10%.
Mã số thuế người mua: ${BuyerTaxCode}
@else
Khách lẻ — không xuất VAT.
@endif

[Bảng items]
| STT     | Tên hàng       | SL      | Đơn vị        |
| #{stt}  | #{ItemName}    | #{Qty}  | #{Unit}       |

Người giao            Người nhận
${SenderName}         ${ReceiverName}

Data:

[
  [
    {
      "Code": "PXK-2026-0042",
      "Date": "20/03/2026",
      "ReceiverName": "Nguyễn Văn A",
      "SenderName": "Trần Thị B",
      "HasVAT": "true",
      "BuyerTaxCode": "0312345678"
    }
  ],
  [
    { "stt": 1, "ItemName": "Hộp giấy A4", "Qty": 100, "Unit": "Hộp" },
    { "stt": 2, "ItemName": "Bút bi xanh", "Qty": 50,  "Unit": "Cái" }
  ]
]

→ Khi HasVAT = "true" → block @if được giữ. Khi "false" → block @else được giữ.


Ví dụ 4 — Báo giá có nhiều bảng (sản phẩm + dịch vụ + chi phí)

Use case: báo giá phức tạp với 3 bảng riêng biệt.

ComType:    ButtonWord
Name:       btnExportQuote
Label:      Xuất báo giá
Template:   https://cdn-tms.softek.com.vn/uploads/quote_template.docx
Query:      "?$expand=Products,Services,Costs&$filter=Id eq {Id}"
System:     quote
PlainText:  "BG_{QuoteNo}"

Template (3 bảng):

BÁO GIÁ ${QuoteNo}
Khách hàng: ${CustomerName}
Ngày: ${Date}

I. SẢN PHẨM
| STT     | Tên SP       | SL      | Giá           | Tổng                   |
| #{stt}  | #{Name}      | #{Qty}  | #{Price:n0}   | #{Qty * Price:n0}      |

II. DỊCH VỤ KÈM THEO
| STT     | Dịch vụ      | Thời gian        | Giá           |
| #{stt}  | #{Name}      | #{Duration}      | #{Price:n0}   |

III. CHI PHÍ KHÁC
| STT     | Khoản mục    | Giá                                                  |
| #{stt}  | #{Name}      | #{Amount:n0}                                         |

Tổng cộng: ${GrandTotal:n0} VND
Bằng chữ: ${GrandTotal:vi}

Data (1 main + 3 tables):

[
  [{ "QuoteNo": "BG-2026-15", "CustomerName": "ACME", "Date": "20/03/2026", "GrandTotal": "55000000" }],

  [ { "stt": 1, "Name": "Phần mềm A", "Qty": 1, "Price": 30000000 } ],

  [
    { "stt": 1, "Name": "Cài đặt",      "Duration": "2 ngày",   "Price": 5000000 },
    { "stt": 2, "Name": "Đào tạo",      "Duration": "5 ngày",   "Price": 10000000 }
  ],

  [
    { "stt": 1, "Name": "Đi lại",       "Amount": 3000000 },
    { "stt": 2, "Name": "Ăn uống",      "Amount": 2000000 },
    { "stt": 3, "Name": "Phí khác",     "Amount": 5000000 }
  ]
]

→ Mỗi bảng template được clone độc lập với data tương ứng (data[1], data[2], data[3]).


Ví dụ 5 — In hàng loạt từ list (nhiều rows được chọn trong grid)

Use case: trong tab Customer list, user check nhiều khách hàng → click “In thư cảm ơn” → mỗi khách 1 file Word.

ComType:        ButtonWord
Name:           btnPrintThanks
Label:          In thư cảm ơn (đã chọn)
Template:       https://cdn-tms.softek.com.vn/uploads/thank_letter.docx
Query:          "?$filter=Id in ({Id})"     # Id trong danh sách selected rows
System:         thanks
PlainText:      "ThuCamOn_{CustomerCode}"
GroupFormat:    Id                         # distinct selected theo Id
Precision:      1                          # bật mode group
CanCache:       true                       # download zip

Khi GroupFormat set + Precision > 0, Core duyệt Selecteds (rows được chọn trong grid cha), distinct theo GroupFormat field, mỗi nhóm 1 file Word riêng.


Ví dụ 6 — Format nhanh: ngày tách phần + đọc số tiếng Anh

Template snippet:

Ngày ${IssueDate:ngay} tháng ${IssueDate:thang} năm ${IssueDate:nam}

Total: ${Amount:n2} USD
In words: ${Amount:en}

Data:

[[{ "IssueDate": "15/03/2026", "Amount": "12345.67" }]]

Output:

Ngày 15 tháng 03 năm 2026

Total: 12,345.67 USD
In words: Twelve thousand three hundred forty-five point sixty-seven dollars

Ví dụ 7 — Conditional ẩn page 2 khi không có data

Use case: hợp đồng có 2 trang; trang 2 chỉ in nếu có phụ lục.

Template:

[ TRANG 1: nội dung chính ]
[ paragraph thường ]

<page2>data[1].length == 0</page2>

[ TRANG 2: phụ lục — sẽ bị xoá nếu data[1] rỗng ]
PHỤ LỤC HỢP ĐỒNG

[Bảng phụ lục — phải có cell chứa #{p2} làm marker]
| STT     | Mục         | Mô tả                                                              |
| #{stt}  | #{Item}     | #{Description}#{p2}                                                |

Data 1 — có phụ lục:

[
  [{ "ContractNo": "HD001" }],
  [{ "stt": 1, "Item": "A", "Description": "..." }]
]

→ Page 2 được giữ, bảng được clone.

Data 2 — không phụ lục:

[
  [{ "ContractNo": "HD001" }],
  []
]

→ Backend đánh giá data[1].length == 0 = truexoá toàn bộ paragraphs + tables ở page 2.


Ví dụ 8 — Font size dynamic theo độ dài nội dung

Use case: tên khách quá dài → font tự co lại để không tràn dòng.

Template:

Khách hàng: <size>this[CustomerName].l > 30 ? 9 : 12</size>${CustomerName}

→ Nếu CustomerName dài hơn 30 ký tự → font 9pt. Ngắn hơn → 12pt.


Ví dụ 9 — Wire click để validate trước export

Use case: kiểm tra điều kiện trước khi xuất Word (vd phải duyệt rồi mới in được).

Cấu hình:

ComType: ButtonWord
Events:  '{"click": "BeforePrintContract"}'

Class C#:

public class ContractDetailBL : PopupEditor
{
    public ContractDetailBL() : base(nameof(Contract)) { }

    public async Task BeforePrintContract(object entity, object guiInfo)
    {
        var c = (Contract)entity;

        // 1) Validate form chuẩn
        if (!await this.IsFormValid()) return;

        // 2) Business: phải đã duyệt
        if (c.StatusId != (int)ApprovalStatusEnum.Approved)
        {
            Toast.Warning("Hợp đồng chưa được duyệt — không thể in.");
            // ⚠ throw để abort flow tiếp theo (LoadData + CreateDocs)
            throw new Exception("Not approved");
        }
    }
}

⚠ Method click chỉ là hook trước. Để abort flow tải data + tạo Word, throw exception. Core spinner sẽ hide nhưng file không tạo.


Cheat sheet placeholder

Bạn muốnViết
Text trong paragraph${FieldName}
Cell trong table#{FieldName}
Số nguyên có dấu phẩy${Total:n0}1,234,567
Số 2 chữ số thập phân${Price:n2}1,234.56
Đọc số tiền tiếng Việt${Total:vi}"Một triệu hai trăm ba mươi tư..."
Đọc số tiền tiếng Anh${Total:en}
Tách ngày từ date “dd/MM/yyyy”${Date:ngay}
Tách tháng${Date:thang}
Tách năm${Date:nam}
Biểu thức math${Qty * Price:n0} (cell: #{Qty * Price:n0})
If / else@if({Field} == {Value}) ... @else ... @endif
Ẩn page 2<page2>condition</page2> + #{p2} marker trong table
Font size dynamic<size>this[Field].l > N ? sizeA : sizeB</size>

Core Docs · Astro · Core.API/wwwRoot/docs