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:ButtonWordPipeline backend:FTP.TMS/Controllers/FileUploadController.cs::CreateDocs(gọi quahttps://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
| Field | Vai trò |
|---|---|
Template | URL public file .docx template chứa các placeholder. Backend GetByteArrayAsync URL này. |
Query | OData/SQL query lấy data. String (?$filter=...) hoặc JS function function(entity, com, formEntity, list){ return "?$filter=..."; }. |
PreQuery | JS function chạy trước Query để chuẩn bị / lookup dependency. Output truyền vào Query. |
System | String flag để backend ReportDataSet pick đúng SQL builder. Vd "contract", "invoice". |
IdField | Fallback dùng làm sys nếu System không set. |
PlainText | Template tên file output với placeholder {FieldName}. Vd "{Code}_{Name}" → "INV001_ACME.docx". Empty → fallback <FieldName>.docx. |
FieldName | Fallback 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. |
CanCache | false (default) → mở file qua Office Online Viewer trên tab mới. true → download .docx về máy. |
Events | JSON {"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ều — data[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
${expr} — placeholder cho paragraph / header / footer
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ả ${...} và #{...})
| Format | Tác dụng |
|---|---|
:n0 | Số không thập phân, có dấu phân tách: 1,234,567 |
:n2 | Số 2 chữ số thập phân: 1,234,567.89 |
:n4 | Số 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. |
:ngay | Date dd/MM/yyyy → lấy ngày: 15 |
:thang | Date dd/MM/yyyy → lấy tháng: 03 |
:nam | Date 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
Clickchạy trướcLoadData()— không có hook “AfterExport”. Muốn xử lý sau khi file tạo xong, dùngMultipleButtonPdf(cóAfterDownload) thay vìButtonWord.
Bẫy hay gặp
- Template URL không public → backend
GetByteArrayAsyncfail → returnnull. Đảm bảo URL truy cập được không cần auth. Querykhông match SQL builder → server trả 500 hoặc empty. Check tênSystemflag đú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/:namwork). - 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 = falsemở Office Online Viewer (cần internet) — preview nhanh không cần Word client.CanCache = truedownload hẳn file.docxvề máy. - Nhiều row được chọn trong grid +
GroupFormat+Precision > 0→ Core distinct theoGroupFormatfield, 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
GroupFormatset +Precision > 0, Core duyệtSelecteds(rows được chọn trong grid cha), distinct theoGroupFormatfield, 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 = true → xoá 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
clickchỉ 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ốn | Viế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> |