From 4b949dff9ba12586bb9abb51a007b22945ac1084 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Sat, 27 Jun 2026 21:37:40 -0700 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=94=AF=E7=A5=A8=E5=88=97?= =?UTF-8?q?=E5=8D=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API/ROLAC.API/Program.cs | 3 + .../Disbursement/CheckPrintLayoutOptions.cs | 86 ++++++ .../Disbursement/CheckPrintService.cs | 253 ++++++++++-------- API/ROLAC.API/appsettings.json | 33 +++ 4 files changed, 268 insertions(+), 107 deletions(-) create mode 100644 API/ROLAC.API/Services/Disbursement/CheckPrintLayoutOptions.cs diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index ee7da4e..52dd0b6 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -168,6 +168,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// Pre-printed check-stock field coordinates; tune in appsettings.json without recompiling. +builder.Services.Configure( + config.GetSection("CheckPrint:Layout")); builder.Services.AddScoped(); // ── Notifications (email via SMTP + Line) ────────────────────────────────── diff --git a/API/ROLAC.API/Services/Disbursement/CheckPrintLayoutOptions.cs b/API/ROLAC.API/Services/Disbursement/CheckPrintLayoutOptions.cs new file mode 100644 index 0000000..61fc3d1 --- /dev/null +++ b/API/ROLAC.API/Services/Disbursement/CheckPrintLayoutOptions.cs @@ -0,0 +1,86 @@ +namespace ROLAC.API.Services.Disbursement; + +/// +/// Field coordinates (in inches) for printing onto pre-printed three-stub check stock. +/// Every position is bound from the "CheckPrint:Layout" section of appsettings.json so alignment +/// can be tuned and reloaded without recompiling. Positions are expressed as an X (absolute from the +/// page's left edge) plus a Y offset within the field's stub; the per-stub origins below place the +/// three stacked regions (check + two identical receipt copies) down the 8.5"x11" page. +/// +public sealed class CheckPrintLayoutOptions +{ + // Calibration nudge (inches): a TextBox renders its text inset down-and-right from the box + // origin by a fixed internal margin + line leading, so configured X/Y don't match the ink. + // These values are subtracted from every position so configured X/Y == actual printed position. + // To recalibrate: set both to 0, print, measure how far the ink sits past a known X/Y, and put + // those differences here (defaults are the measured inset for this stock). + public float TextInsetX { get; set; } = 0.13f; + public float TextInsetY { get; set; } = 0.15f; + + // Stub origins — top edge of each region, inches from the top of the page. + public float CheckOriginY { get; set; } = 0f; + public float Receipt1OriginY { get; set; } = 3.67f; + public float Receipt2OriginY { get; set; } = 7.33f; + + // Check stub fields (offset within the check stub). + public FieldPos Payee { get; set; } = new() { X = 1.25f, OffsetY = 1.75f, FontSize = 11, Bold = true }; + public FieldPos AmountNumeric { get; set; } = new() { X = 6.50f, OffsetY = 1.75f, FontSize = 11, Bold = true }; + public FieldPos AmountWords { get; set; } = new() { X = 0.60f, OffsetY = 2.20f, FontSize = 10 }; + public FieldPos Memo { get; set; } = new() { X = 0.60f, OffsetY = 2.90f, FontSize = 9 }; + public FieldPos CheckDate { get; set; } = new() { X = 6.50f, OffsetY = 1.25f, FontSize = 10 }; + + // Receipt stub fields (offset within a receipt stub — shared by both identical copies). + public FieldPos ReceiptPayee { get; set; } = new() { X = 1.00f, OffsetY = 0.30f, FontSize = 10, Bold = true }; + public FieldPos ReceiptAmount { get; set; } = new() { X = 6.50f, OffsetY = 0.30f, FontSize = 10, Bold = true }; + public FieldPos ReceiptMemo { get; set; } = new() { X = 1.00f, OffsetY = 0.60f, FontSize = 9 }; + public FieldPos ReceiptDate { get; set; } = new() { X = 6.50f, OffsetY = 0.60f, FontSize = 9 }; + + // Voucher detail grid (offsets within a receipt stub). + public VoucherGridOptions Grid { get; set; } = new(); +} + +/// One printable field: where it sits and how it renders. +public sealed class FieldPos +{ + /// Absolute X from the page's left edge, in inches. + public float X { get; set; } + + /// Y offset within the field's stub, in inches (added to the stub origin). + public float OffsetY { get; set; } + + public float FontSize { get; set; } = 10; + public bool Bold { get; set; } +} + +/// +/// Two-column voucher detail grid on each receipt stub: 6 rows per column, 12 expense lines max, +/// filled column-major (lines 1-6 left, 7-12 right). All offsets are within the receipt stub. +/// +public sealed class VoucherGridOptions +{ + /// Left edge of the left column block, in inches from the page's left edge. + public float OriginX { get; set; } = 0.60f; + + /// Y offset (within the stub) of the first data row. + public float OffsetY { get; set; } = 1.10f; + + public float RowHeight { get; set; } = 0.22f; + + /// Horizontal gap between the left and right column blocks, in inches. + public float ColumnGap { get; set; } = 0.30f; + + public float DateWidth { get; set; } = 0.85f; + public float DescWidth { get; set; } = 2.10f; + public float AmountWidth { get; set; } = 0.80f; + + /// Draw our own Date/Description/Amount headers. Set false if the stock pre-prints them. + public bool ShowGridHeaders { get; set; } = true; + + /// Y offset (within the stub) of the header row. + public float HeaderOffsetY { get; set; } = 0.88f; + + /// Y offset (within the stub) of the "...and N more lines" overflow hint. + public float OverflowOffsetY { get; set; } = 2.55f; + + public float FontSize { get; set; } = 8.5f; +} diff --git a/API/ROLAC.API/Services/Disbursement/CheckPrintService.cs b/API/ROLAC.API/Services/Disbursement/CheckPrintService.cs index 0e21b97..5b6e1ee 100644 --- a/API/ROLAC.API/Services/Disbursement/CheckPrintService.cs +++ b/API/ROLAC.API/Services/Disbursement/CheckPrintService.cs @@ -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; /// -/// 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 +/// (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. /// public class CheckPrintService : ICheckPrintService { + private readonly CheckPrintLayoutOptions _layout; + + public CheckPrintService(IOptions layout) + { + _layout = layout.Value; + } + public Task 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(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); + } + + /// + /// 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. + /// + 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); + } + } + + /// Places one configured field's value at its stub-relative position. + 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); + } + + /// + /// 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 into it. + /// + 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 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(", ", diff --git a/API/ROLAC.API/appsettings.json b/API/ROLAC.API/appsettings.json index cdb7947..538c393 100644 --- a/API/ROLAC.API/appsettings.json +++ b/API/ROLAC.API/appsettings.json @@ -55,5 +55,38 @@ }, "Ai": { "Provider": "Claude" + }, + "CheckPrint": { + "//": "Field coordinates (inches) for pre-printed three-stub check stock. X = from page left edge; OffsetY = within the field's stub, added to the stub origin. Tune to match your stock, then restart — no recompile.", + "Layout": { + "//cal": "TextInset* compensates for the fixed inset the TextBox adds around its text so configured X/Y == actual ink. To recalibrate, set both to 0, print, measure the drift past a known X/Y, and enter the differences here.", + "TextInsetX": 0.13, + "TextInsetY": 0.15, + "CheckOriginY": 0.0, + "Receipt1OriginY": 3.67, + "Receipt2OriginY": 7.33, + "Payee": { "X": 1.1, "OffsetY": 1.35, "FontSize": 11, "Bold": true }, + "AmountNumeric": { "X": 7.0, "OffsetY": 1.35, "FontSize": 12, "Bold": true }, + "AmountWords": { "X": 0.30, "OffsetY": 1.67, "FontSize": 10 }, + "Memo": { "X": 0.60, "OffsetY": 2.85, "FontSize": 9 }, + "CheckDate": { "X": 7.00, "OffsetY": 0.90, "FontSize": 10 }, + "ReceiptPayee": { "X": 1.00, "OffsetY": 0.30, "FontSize": 10, "Bold": true }, + "ReceiptAmount": { "X": 6.50, "OffsetY": 0.30, "FontSize": 10, "Bold": true }, + "ReceiptMemo": { "X": 1.00, "OffsetY": 0.60, "FontSize": 9 }, + "ReceiptDate": { "X": 6.50, "OffsetY": 0.60, "FontSize": 9 }, + "Grid": { + "OriginX": 0.60, + "OffsetY": 1.10, + "RowHeight": 0.22, + "ColumnGap": 0.30, + "DateWidth": 0.85, + "DescWidth": 2.10, + "AmountWidth": 0.80, + "ShowGridHeaders": true, + "HeaderOffsetY": 0.88, + "OverflowOffsetY": 2.55, + "FontSize": 8.5 + } + } } }