更新支票列印
ci-cd-vm / ci-cd (push) Successful in 1m56s

This commit is contained in:
Chris Chen
2026-06-27 21:37:40 -07:00
parent 773d38d838
commit 4b949dff9b
4 changed files with 268 additions and 107 deletions
@@ -1,17 +1,28 @@
using System.Drawing;
using System.Globalization;
using DevExpress.Office;
using DevExpress.XtraRichEdit;
using DevExpress.XtraRichEdit.API.Native;
using Microsoft.Extensions.Options;
namespace ROLAC.API.Services.Disbursement;
/// <summary>
/// Renders a check on 8.5"x11" stock using the DevExpress Office (RichEdit) API:
/// a check block on top followed by two identical ledger detail stubs. The layout is
/// built programmatically (no external .docx template) and exported to PDF.
/// Renders a check onto pre-printed 8.5"x11" three-stub stock using the DevExpress Office
/// (RichEdit) API. Fields are placed as absolutely-positioned floating TextBoxes so they align to
/// the boxes printed on the stock; every coordinate comes from <see cref="CheckPrintLayoutOptions"/>
/// (bound from "CheckPrint:Layout" in appsettings.json) so alignment can be tuned without recompiling.
/// The page is one check stub on top followed by two identical receipt copies.
/// </summary>
public class CheckPrintService : ICheckPrintService
{
private readonly CheckPrintLayoutOptions _layout;
public CheckPrintService(IOptions<CheckPrintLayoutOptions> layout)
{
_layout = layout.Value;
}
public Task<Stream> RenderPdfAsync(CheckPrintModel model)
{
using var server = new RichEditDocumentServer();
@@ -21,14 +32,18 @@ public class CheckPrintService : ICheckPrintService
{
doc.Unit = DocumentUnit.Inch;
var section = doc.Sections[0];
section.Page.Width = 8.5f;
section.Page.Width = 8.5f;
section.Page.Height = 11f;
section.Margins.Left = section.Margins.Right = 0.6f;
section.Margins.Top = section.Margins.Bottom = 0.5f;
BuildCheckBlock(doc, model);
BuildStub(doc, model, "PAYMENT ADVICE — DETAIL");
BuildStub(doc, model, "PAYMENT ADVICE — RECORD COPY");
// Floating TextBoxes must anchor to a paragraph; everything is positioned relative to
// the page, so a single empty anchor paragraph at the document start is enough.
var anchor = doc.Paragraphs[0];
BuildCheckStub(doc, anchor, model);
BuildReceiptStub(doc, anchor, model, _layout.Receipt1OriginY);
BuildReceiptStub(doc, anchor, model, _layout.Receipt2OriginY);
}
finally
{
@@ -41,6 +56,125 @@ public class CheckPrintService : ICheckPrintService
return Task.FromResult<Stream>(ms);
}
// ── check page builders (absolute positioning) ─────────────────────────────
private void BuildCheckStub(Document doc, Paragraph anchor, CheckPrintModel model)
{
var check = model.Check;
var originY = _layout.CheckOriginY;
PlaceField(doc, anchor, _layout.Payee, originY, 4.5f, check.PayeeName);
PlaceField(doc, anchor, _layout.AmountNumeric, originY, 1.6f, FormatCurrency(check.Amount,13), rightAlign: false);
PlaceField(doc, anchor, _layout.AmountWords, originY, 6.0f, model.AmountInWords);
PlaceField(doc, anchor, _layout.CheckDate, originY, 1.6f, check.CheckDate.ToString("MM/dd/yyyy"));
if (!string.IsNullOrWhiteSpace(check.Memo))
PlaceField(doc, anchor, _layout.Memo, originY, 4.5f, check.Memo);
}
private void BuildReceiptStub(Document doc, Paragraph anchor, CheckPrintModel model, float originY)
{
var check = model.Check;
PlaceField(doc, anchor, _layout.ReceiptPayee, originY, 4.5f, "Pay To The Order Of: " + check.PayeeName);
PlaceField(doc, anchor, _layout.ReceiptAmount, originY, 1.6f, "Amount: " + FormatCurrency(check.Amount), rightAlign: true);
PlaceField(doc, anchor, _layout.ReceiptDate, originY, 1.6f, check.CheckDate.ToString("MM/dd/yyyy"));
if (!string.IsNullOrWhiteSpace(check.Memo))
PlaceField(doc, anchor, _layout.ReceiptMemo, originY, 4.5f, check.Memo);
BuildVoucherGrid(doc, anchor, model, originY);
}
/// <summary>
/// Two-column voucher detail grid: up to 12 expense lines (6 per column), filled column-major.
/// Beyond 12 lines, prints the first 12 plus an overflow hint so the receipt total stays honest.
/// </summary>
private void BuildVoucherGrid(Document doc, Paragraph anchor, CheckPrintModel model, float originY)
{
var grid = _layout.Grid;
var blockWidth = grid.DateWidth + grid.DescWidth + grid.AmountWidth;
const int rowsPerColumn = 6;
const int maxLines = rowsPerColumn * 2;
float ColumnX(int column) => grid.OriginX + column * (blockWidth + grid.ColumnGap);
void PlaceRow(float x, float y, string date, string description, string amount, bool bold)
{
PlaceText(doc, anchor, x, y, grid.DateWidth, date, grid.FontSize, bold, rightAlign: false);
PlaceText(doc, anchor, x + grid.DateWidth, y, grid.DescWidth, description, grid.FontSize, bold, rightAlign: false);
PlaceText(doc, anchor, x + grid.DateWidth + grid.DescWidth, y, grid.AmountWidth, amount, grid.FontSize, bold, rightAlign: true);
}
if (grid.ShowGridHeaders)
{
var headerY = originY + grid.HeaderOffsetY;
for (var column = 0; column < 2; column++)
PlaceRow(ColumnX(column), headerY, "Date", "Description", "Amount", bold: true);
}
var printed = Math.Min(model.Lines.Count, maxLines);
for (var i = 0; i < printed; i++)
{
var line = model.Lines[i];
var column = i < rowsPerColumn ? 0 : 1;
var row = i % rowsPerColumn;
var y = originY + grid.OffsetY + row * grid.RowHeight;
var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? "";
PlaceRow(ColumnX(column), y, date, line.Description, FormatCurrency(line.Amount), bold: false);
}
if (model.Lines.Count > maxLines)
{
var remaining = model.Lines.Count - maxLines;
var remainingTotal = model.Lines.Skip(maxLines).Sum(line => line.Amount);
var hint = $"…and {remaining} more line(s) ({FormatCurrency(remainingTotal)})";
PlaceText(doc, anchor, grid.OriginX, originY + grid.OverflowOffsetY,
blockWidth * 2 + grid.ColumnGap, hint, grid.FontSize, bold: false, rightAlign: false);
}
}
/// <summary>Places one configured field's value at its stub-relative position.</summary>
private void PlaceField(Document doc, Paragraph anchor, FieldPos field, float stubOriginY,
float width, string text, bool rightAlign = false)
{
PlaceText(doc, anchor, field.X, stubOriginY + field.OffsetY, width, text,
field.FontSize, field.Bold, rightAlign);
}
/// <summary>
/// Inserts a borderless, fill-less floating TextBox positioned absolutely relative to the page
/// (X from the left edge, Y from the top edge) and writes <paramref name="text"/> into it.
/// </summary>
private void PlaceText(Document doc, Paragraph anchor, float x, float y, float width,
string text, float fontSize, bool bold, bool rightAlign)
{
var shape = doc.Shapes.InsertTextBox(anchor.Range.Start);
shape.HorizontalAlignment = ShapeHorizontalAlignment.None;
shape.VerticalAlignment = ShapeVerticalAlignment.None;
shape.RelativeHorizontalPosition = ShapeRelativeHorizontalPosition.Page;
shape.RelativeVerticalPosition = ShapeRelativeVerticalPosition.Page;
// Pull the box up-and-left by the calibration inset so the text inside lands exactly on the
// configured (x, y) rather than down-and-right of it.
shape.Offset = new PointF(x - _layout.TextInsetX, y - _layout.TextInsetY);
shape.Size = new SizeF(width, Math.Max(0.2f, fontSize / 72f + 0.08f));
shape.Fill.SetNoFill();
shape.Line.Fill.SetNoFill();
var body = shape.ShapeFormat.TextBox.Document;
var range = body.InsertText(body.Range.Start, text);
var characters = body.BeginUpdateCharacters(range);
characters.FontSize = fontSize;
characters.Bold = bold;
body.EndUpdateCharacters(characters);
if (rightAlign)
{
var paragraphs = body.BeginUpdateParagraphs(range);
paragraphs.Alignment = ParagraphAlignment.Right;
body.EndUpdateParagraphs(paragraphs);
}
}
public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model)
{
using var server = new RichEditDocumentServer();
@@ -131,110 +265,15 @@ public class CheckPrintService : ICheckPrintService
private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? "");
private static void BuildCheckBlock(Document doc, CheckPrintModel m)
{
var issuer = m.Issuer;
var check = m.Check;
AppendLine(doc, issuer.Name, bold: true, size: 13);
var issuerAddr = JoinAddress(issuer.Address, issuer.City, issuer.State, issuer.ZipCode);
if (!string.IsNullOrWhiteSpace(issuerAddr)) AppendLine(doc, issuerAddr, size: 9);
if (!string.IsNullOrWhiteSpace(issuer.BankName)) AppendLine(doc, issuer.BankName, size: 9);
AppendLine(doc, "");
AppendLine(doc, $"Check No: {check.CheckNumber} Date: {check.CheckDate:MM/dd/yyyy}", bold: true, size: 10);
AppendLine(doc, "");
AppendLine(doc, $"PAY TO THE ORDER OF: {check.PayeeName}", bold: true, size: 11);
var payeeAddr = JoinAddress(check.PayeeAddress, check.PayeeCity, check.PayeeState, check.PayeeZip);
if (!string.IsNullOrWhiteSpace(payeeAddr)) AppendLine(doc, payeeAddr, size: 9);
AppendLine(doc, "");
AppendLine(doc, $"AMOUNT: {FormatCurrency(check.Amount)}", bold: true, size: 12);
AppendLine(doc, m.AmountInWords, size: 10);
if (!string.IsNullOrWhiteSpace(check.Memo)) { AppendLine(doc, ""); AppendLine(doc, $"Memo: {check.Memo}", size: 9); }
AppendLine(doc, "");
AppendLine(doc, "____________________________________", size: 10);
AppendLine(doc, "Authorized Signature", size: 8);
AppendSeparator(doc);
}
private static void BuildStub(Document doc, CheckPrintModel m, string title)
{
var check = m.Check;
AppendLine(doc, title, bold: true, size: 10);
AppendLine(doc, $"Check No: {check.CheckNumber} Date: {check.CheckDate:MM/dd/yyyy} Payee: {check.PayeeName}", size: 9);
AppendLine(doc, "");
var rows = m.Lines.Count + 2; // header + lines + total
var table = doc.Tables.Create(doc.Range.End, rows, 3, AutoFitBehaviorType.AutoFitToWindow);
table.Borders.InsideHorizontalBorder.LineStyle = TableBorderLineStyle.Single;
table.Borders.Top.LineStyle = table.Borders.Bottom.LineStyle = TableBorderLineStyle.Single;
SetCell(doc, table[0, 0], "Date", bold: true);
SetCell(doc, table[0, 1], "Description", bold: true);
SetCell(doc, table[0, 2], "Amount", bold: true, right: true);
for (var i = 0; i < m.Lines.Count; i++)
{
var line = m.Lines[i];
var r = i + 1;
// CheckLine snapshots description; date comes from the source expense if loaded.
var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? "";
SetCell(doc, table[r, 0], date);
SetCell(doc, table[r, 1], line.Description);
SetCell(doc, table[r, 2], FormatCurrency(line.Amount), right: true);
}
var totalRow = rows - 1;
SetCell(doc, table[totalRow, 0], "");
SetCell(doc, table[totalRow, 1], "TOTAL", bold: true, right: true);
SetCell(doc, table[totalRow, 2], FormatCurrency(check.Amount), bold: true, right: true);
AppendLine(doc, "");
if (check.ReceiptSignedAt is { } signedAt)
AppendLine(doc, $"Received by: {check.ReceiptSignedName} on {signedAt:MM/dd/yyyy HH:mm}", size: 9);
AppendSeparator(doc);
}
// ── low-level helpers ──────────────────────────────────────────────────────
private static void AppendLine(Document doc, string text, bool bold = false, float size = 10)
private static string FormatCurrency(decimal amount, int paddingAstric = 0)
{
var range = doc.AppendText(text + "\r\n");
var cp = doc.BeginUpdateCharacters(range);
cp.Bold = bold;
cp.FontSize = size;
doc.EndUpdateCharacters(cp);
var c = (CultureInfo)CultureInfo.GetCultureInfo("en-US").Clone();
c.NumberFormat.CurrencySymbol = "";
string formatedAmount = amount.ToString("#,##0.00", CultureInfo.GetCultureInfo("en-US"));
return paddingAstric > 0 ? formatedAmount.PadLeft(paddingAstric, '*') : formatedAmount;
}
private static void AppendSeparator(Document doc)
{
AppendLine(doc, "");
AppendLine(doc, "------------------------------------------------------------------------------------------", size: 8);
AppendLine(doc, "");
}
private static void SetCell(Document doc, TableCell cell, string text, bool bold = false, bool right = false)
{
var range = doc.InsertText(cell.ContentRange.Start, text);
var cp = doc.BeginUpdateCharacters(range);
cp.Bold = bold;
cp.FontSize = 9;
doc.EndUpdateCharacters(cp);
if (right)
{
var pp = doc.BeginUpdateParagraphs(range);
pp.Alignment = ParagraphAlignment.Right;
doc.EndUpdateParagraphs(pp);
}
}
private static string FormatCurrency(decimal amount) =>
amount.ToString("C2", CultureInfo.GetCultureInfo("en-US"));
private static string JoinAddress(string? addr, string? city, string? state, string? zip)
{
var cityLine = string.Join(", ",