Bài viết kỹ thuật

Tạo báo cáo Excel theo mẫu trong Delphi bằng HotXLS

Cách đáng tin cậy để tạo ra một báo cáo Excel có style từ Delphi là bắt đầu từ một workbook mà một designer đã dựng sẵn. Ai đó ở phòng tài chính dàn layout cho hóa đơn trong Excel: logo, tiêu đề cột, viền của dải chi tiết, hàng tổng in đậm, định dạng tiền tệ. Code của bạn mở tệp đó lên, thả dữ liệu sống vào những ô mà designer đã dành riêng cho nó, rồi lưu kết quả lại. Hình thức là của họ; con số là của bạn. HotXLS, một thư viện thuần cho Delphi và C++Builder đọc và ghi workbook XLS và XLSX mà không cần điều khiển Excel, trao cho bạn ba thao tác mà cách làm này cần: tìm một ô theo nội dung text của nó, sao chép một range còn nguyên style và công thức, và chèn dòng để mọi thứ bên dưới dịch xuống cùng với dữ liệu

Quy tắc duy nhất phân biệt một generator sống sót qua các lần sửa template với một generator gãy ngay lần sửa đầu tiên là: đừng bao giờ định vị ô bằng số dòng và số cột viết chết trong code. Một template là một tài liệu mà người khác chỉnh sửa. Đội tài chính thêm một dòng thuế, nâng chiều cao của hàng logo, sắp lại khối địa chỉ, và định dạng tệp chẳng giúp gì bạn cả: một lần lưu BIFF hay OOXML vẫn thành công bất kể dòng 10 còn mang đúng ý nghĩa như quý trước hay không. Một generator ghi dòng chi tiết đầu tiên vào dòng 10 viết chết trong code sẽ, ngay lần đầu tiên có ai đó chèn một khối phía trên phần chi tiết, đóng dấu các dòng hàng hóa lên nhầm ô và cộng một range tổng không còn bao trùm dữ liệu nữa. Không có gì ném lỗi, mọi lần lưu đều trả về thành công, và tín hiệu duy nhất là một khách hàng nhận ra một hóa đơn sai

Sơ đồ pipeline mẫu của HotXLS trong Delphi: neo token bằng FindText, mở rộng dải chi tiết, xác minh tổng đã tính, rồi lưu
Sinh báo cáo từ template trong Delphi chạy như bốn giai đoạn HotXLS: neo các token, giãn dải chi tiết, xác minh tổng đã tính, rồi bàn giao

Neo mọi tọa độ vào một token giữ chỗ

Cách sửa là làm cho template tự mang theo tọa độ của chính nó. Designer viết các token như {{CUSTOMER}}, {{DATE}}, và {{DETAIL_START}} vào những ô mà generator phải chạm tới, và generator tính ra mọi vị trí ngay lúc chạy dựa vào nơi nó tìm thấy các token đó. Các lần sửa layout không còn quan trọng nữa, vì token di chuyển cùng ô mà nó đang nằm trong đó. Nửa còn lại của hợp đồng là quy tắc thất bại: nếu một token bắt buộc bị thiếu, job phải dừng lại trước khi bất kỳ dữ liệu khách hàng nào chạm tới tệp. Một template đã trôi dạt nên tạo ra một job ticket thất bại, chứ không phải một tài liệu đã bàn giao

Tìm các token: FindText và ReplaceText

Cả hai họ class của HotXLS đều phơi ra khả năng tìm kiếm ở cấp worksheet. FindText trả về dòng và cột của ô đầu tiên có text khớp, kèm một overload thêm tùy chọn phân biệt hoa thường. ReplaceText thay thế mọi lần xuất hiện và trả về số lần nó đã đổi. Hai hàm này bao trùm hai loại token mà bạn thường gặp. Một điểm neo đơn lẻ như tên khách hàng thì bạn định vị nó một lần rồi ghi ngay cạnh; một token chỉ nên xuất hiện đúng một lần, như ngày báo cáo, thì bạn thay thế rồi kiểm tra số lượng. Ở phía XLSX, một lượt điền tự neo mình theo cách này trông như sau:

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  R, C: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.Open('invoice-template.xlsx') <> 1 then
      raise Exception.Create('Cannot open invoice template');
    Sheet := Book.Sheets[0];               // TXLSXSheets.Items bắt đầu từ 0

    if not Sheet.FindText('{{CUSTOMER}}', R, C) then
      raise Exception.Create('Template drift: {{CUSTOMER}} anchor missing');
    Sheet.Cells[R, C].Value := 'ACME Corp';

    if Sheet.ReplaceText('{{DATE}}',
         FormatDateTime('yyyy-mm-dd', Date)) = 0 then
      raise Exception.Create('Template drift: {{DATE}} token missing');
    // phần mở rộng dòng chi tiết và lưu tệp nằm ở đoạn dưới
  finally
    Book.Free;
  end;
end;

Có hai chi tiết đáng quan tâm. Thứ nhất, FindTextReplaceText khớp theo giá trị text của ô; một token nhúng bên trong một chuỗi công thức là vô hình đối với chúng, nên các token giữ chỗ chỉ nên nằm trong các ô thuần, không bao giờ nằm bên trong công thức. Thứ hai, số lần thay thế chính là bộ phát hiện trôi dạt của bạn. Một template lẽ ra phải chứa đúng một token {{DATE}} nhưng lại báo cáo không có lần thay thế nào đã bị chỉnh sửa, và việc ném ra một exception ngay lúc đó chính xác là thứ biến sự trôi dạt layout âm thầm thành một thất bại lộ rõ

Nhân bản dòng chi tiết mà không mất style hay công thức

Phần chi tiết của một hóa đơn lớn dần theo dữ liệu. Ghi giá trị thẳng vào các dòng trống bên dưới dòng mẫu sẽ vứt bỏ mọi thứ mà designer đã chuẩn bị: viền, định dạng số, các công thức theo từng dòng. Mẫu hình giữ lại được tất cả những điều đó là để lại một dòng mẫu đã được định dạng đầy đủ trong template rồi nhân bản nó cho mỗi hàng mục. CopyRange nhân đôi style và công thức chỉ trong một lệnh gọi, sau đó generator chỉ cần ghi đè lên các ô giá trị

Sơ đồ các điểm neo token trong một mẫu HotXLS Delphi, nơi một placeholder bị thiếu làm công việc thất bại trước khi bất kỳ dữ liệu nào được ghi
Các token template mang tọa độ riêng của chúng, và một token vắng mặt dừng công việc trước khi bất kỳ dữ liệu nào được ghi
const
  DetailRow = 10;            // dòng mẫu đã định dạng trong template
var
  I: Integer;
begin
  // Mở khoảng trống trước khối tổng trước tiên, để range SUM
  // bên dưới dải chi tiết giãn ra cùng với dữ liệu.
  if Length(Items) > 1 then
    Sheet.InsertRows(DetailRow + 1, Length(Items) - 1);

  for I := 0 to High(Items) do
  begin
    if I > 0 then              // nhân bản style + công thức từ dòng mẫu
      Sheet.CopyRange(DetailRow, 1, DetailRow, 5, DetailRow + I, 1);
    Sheet.Cells[DetailRow + I, 1].Value := Items[I].Name;
    Sheet.Cells[DetailRow + I, 2].Value := Items[I].Qty;
    Sheet.Cells[DetailRow + I, 3].Value := Items[I].UnitPrice;
    Sheet.Cells[DetailRow + I, 4].Formula :=
      Format('B%d*C%d', [DetailRow + I, DetailRow + I]);  // không có tiền tố '='
  end;
end;

Hãy chú ý kỹ vào phép gán công thức. Thuộc tính Formula của XLSX nhận biểu thức mà không có dấu bằng ở đầu, trong khi facade XLS lại mong đợi '=B10*C10' được gán qua Value. Trộn lẫn hai quy ước này là lỗi chuyển đổi phổ biến nhất giữa hai họ class, và nó thất bại mà không hề than phiền: ô đó đơn giản chỉ giữ một chuỗi nguyên văn mà Excel hiển thị như text. Nếu template trang trí dải chi tiết bằng các hàng tiêu đề đã merge, hãy nhớ rằng chỉ ô trên-cùng-bên-trái của một vùng merge mới mang giá trị. Các quy tắc layout trong bài viết đồng hành về ô merge trong template báo cáo theo hướng layout giải thích vì sao các vùng merge nên nằm hoàn toàn ngoài dải dữ liệu

InsertRows dịch chuyển những gì, và để lại phía sau những gì

Chèn dòng phía trước khối tổng chính là thứ giữ cho một range SUM luôn giãn ra khi phần chi tiết lớn dần. Ở phía XLSX, InsertRows mang theo cả một danh sách dài các cấu trúc phụ thuộc cùng với các ô: vùng merge, chiều cao dòng, hyperlink, comment, frozen pane, vùng autofilter, conditional format, data validation, table, defined name, và các điểm neo hình ảnh cùng chart. Có một ranh giới trong danh sách đó đáng ghi nhớ. Việc viết lại công thức chỉ với tới các tham chiếu trong cùng một sheet. Một công thức trên một sheet tổng hợp trỏ vào vùng đã bị dịch chuyển sẽ giữ nguyên tọa độ cũ và âm thầm đọc nhầm ô, đó là lý do vì sao các tổng kéo qua nhiều sheet an toàn hơn khi được biểu diễn qua các tên ở cấp workbook. Bài viết đồng hành về defined name và công thức xuyên sheet đi sâu vào mẫu hình đó

Định dạng XLS kiểu cũ lại vạch ranh giới ở một chỗ khắt khe hơn. HotXLS giữ pivot table, query table, và các kết nối dữ liệu ngoài trong tệp BIFF dưới dạng các khối byte thô. Chúng sống sót qua việc mở và lưu mà không đổi, nhưng chúng không được model hóa, nên việc chèn dòng không bao giờ đụng tới chúng. Một template đặt một pivot table bên dưới một khối chi tiết đang mở rộng sẽ lưu mà chẳng có cảnh báo nào cả, trong khi hình chữ nhật nguồn của pivot trôi dần khỏi dữ liệu. Lối thoát nằm ở cấu trúc, không phải ở phòng thủ: giữ nội dung pivot và query trên những sheet mà generator không bao giờ chèn vào, và sự lỗi thời đó không thể xảy ra

Sơ đồ những gì InsertRows của HotXLS di chuyển trong XLSX và các ranh giới công thức liên-sheet cùng pivot BIFF mà bộ sinh Delphi phải tôn trọng
InsertRows mang các cấu trúc phụ thuộc theo xuống dưới trên XLSX, trong khi công thức chéo sheet và các khối thô BIFF vạch ranh giới

Tính lại trước khi bàn giao, hoặc biết rõ vì sao bạn đã bỏ qua nó

HotXLS không tính giá trị công thức trong lúc SaveAs. Khi một con người mở tệp lên, Excel sẽ tự tính lại mọi thứ (facade XLS phơi ra CalculationModeRecalcOnSave nếu bạn cần điều khiển việc đó), nên một báo cáo gửi tới hộp thư của một con người thì không cần gì thêm từ phía bạn cả. Bức tranh thay đổi ngay khi workbook đó nuôi một chương trình khác. Xuất CSV ghi công thức ra dưới dạng text nguyên văn và không bao giờ tính chúng, và bất kỳ parser nào ở phía sau tin tưởng vào giá trị cache sẽ đọc phải những con số cũ hoặc ô trống. Với những đường đó, hãy tính toán ngay trên server bằng Calculate, hàm này tính giá trị của một biểu thức tùy ý dựa trên workbook đã nạp và trả kết quả về:

var
  Total: Variant;
  LastDetail: Integer;
begin
  LastDetail := DetailRow + Length(Items) - 1;
  Total := Book.Calculate(Format('SUM(Invoice!D%d:D%d)',
    [DetailRow, LastDetail]));
  if (not VarIsNumeric(Total)) or
     (Abs(Total - ExpectedTotal) > 0.005) then
    raise Exception.Create('Invoice total does not match the order record');

  if Book.SaveAs('invoice-2026-0611.xlsx') <> 1 then
    raise Exception.Create('Save failed: check output path and permissions');
end;

Kiểm tra tổng đã tính được so với bản ghi đơn hàng trước khi lưu là một khoản bảo hiểm rẻ mà mang lại lợi ích lớn. Nó biến một hóa đơn sai thành một job thất bại. Một operator có thể chạy lại một job thất bại trong vài giây; còn một hóa đơn sai đã nằm trong hộp thư khách hàng thì tốn của một account manager một lời xin lỗi và một lần sửa sai

Hai họ class, một thuật toán

Cùng một logic đó chuyển đổi được giữa hai định dạng, nhưng không phải cùng một đoạn code. TXLSWorkbook cho .xls kiểu cũ dựa trên interface và được đếm tham chiếu, với chỉ mục sheet bắt đầu từ 1, và bạn không bao giờ tự tay free nó. TXLSXWorkbook cho .xlsx là một object thuần mà bạn phải tự free trong một khối try..finally, với chỉ mục sheet bắt đầu từ 0 và quy ước công thức đã trình bày ở trên. FindText, ReplaceText, CopyRange, và InsertRows đều tồn tại ở cả hai phía, nên hình dạng neo-nhân bản-tính lại được chuyển giao trơn tru. Lời khuyên thực tế là hãy chốt vào một định dạng cho mỗi pipeline, hoặc giấu hai vòng đời object đó sau một adapter mỏng của riêng bạn thay vì rải sự khác biệt đó khắp generator

Kích thước hiếm khi quan trọng với loại báo cáo mà mẫu hình này tạo ra. Nhân bản một dòng có style vài nghìn lần chẳng là gì với phần cứng hiện nay. Đường lưu chỉ trở thành nút thắt cổ chai khi một dải chi tiết chạm tới số dòng sáu chữ số, và ngay lúc đó việc bật StreamingWrite sẽ đẩy XML của worksheet thẳng vào package đầu ra thay vì đệm nó lại; bài viết về streaming write cho các job batch phía server trình bày khi nào sự đánh đổi đó đáng thực hiện. Chart hành xử giống hệt phần còn lại của layout: ở phía XLSX, cả điểm neo của chart lẫn các tham chiếu series của nó đều dịch chuyển khi InsertRows chạy phía trên chúng, nên một chart nằm dưới hàng tổng vẫn gắn đúng với dữ liệu, còn ở phía XLS, chart nằm trên các chart sheet riêng của chúng và, giống như pivot table, không bao giờ dịch chuyển. Đó là thêm một lý do nữa để giữ các sheet trình bày tránh xa sheet mà generator mở rộng

Cách tiếp cận neo-nhân bản-tính lại này để cho một designer làm chủ hình thức của một workbook, trong khi code của bạn làm chủ nội dung nó nói, và đó thường chính là điều khiến kết quả Excel được tạo tự động đáng để duy trì. Các lệnh gọi tìm kiếm, sao chép, và chèn được trình bày ở đây, cùng với engine công thức dùng cho bước kiểm tra tổng trước khi bàn giao, đi kèm sẵn trong HotXLS Delphi Component cho Delphi và C++Builder