dev #255
@@ -49,24 +49,25 @@ export function analyzeOutgoingDocumentLayout(layout: InvoicePdfLayout, maxConte
|
||||
if (last?.mode === "title" && page.page < layout.pageCount) {
|
||||
warnings.push({ type: "orphanTitle", page: page.page, message: `Überschrift ${last.pos || last.index + 1} steht allein am Seitenende.`, insertPagebreakBeforeIndex: last.index })
|
||||
}
|
||||
if (!page.rows.length && page.page > 1 && !blocks.some((block) => block.page === page.page)) {
|
||||
if (!page.rows.length && page.page > 1 && page.page !== layout.paymentQr?.page && !blocks.some((block) => block.page === page.page)) {
|
||||
warnings.push({ type: "emptyPage", page: page.page, message: "Die Seite ist leer, vermutlich durch doppelte oder abschließende Seitenumbrüche." })
|
||||
}
|
||||
}
|
||||
|
||||
// The payment QR code is drawn at a fixed spot on the last page, right next to the amount column.
|
||||
// The renderer moves the payment QR code to an extra page when the amounts reach its spot.
|
||||
const qr = layout.paymentQr
|
||||
if (qr) {
|
||||
const covered = layout.rows.find((row) => row.page === qr.page && row.mode !== "pagebreak" && row.bottomMm > qr.topMm)
|
||||
const totalsCovered = layout.totals?.page === qr.page && layout.totals.bottomMm > qr.topMm
|
||||
if (covered || totalsCovered) {
|
||||
warnings.push({
|
||||
type: "paymentQrOverlap",
|
||||
page: qr.page,
|
||||
message: `Der Überweisungs-QR-Code (ab ${round(qr.topMm)} mm, rechts) überdeckt ${covered ? `Position ${covered.pos || covered.index + 1}` : "den Summenblock"}. Positionen und Summen auf der letzten Seite müssen oberhalb enden.`,
|
||||
insertPagebreakBeforeIndex: covered && covered.index > 0 ? covered.index : layout.rows.length,
|
||||
})
|
||||
}
|
||||
const qrAlone = qr && !blocks.some((block) => block.page === qr.page)
|
||||
if (qr && qrAlone) {
|
||||
const previous = pages[qr.page - 2]
|
||||
const reaching = previous?.rows.find((row) => row.bottomMm > qr.topMm)
|
||||
let before = reaching ? previous.rows.indexOf(reaching) : -1
|
||||
while (before > 0 && previous.rows[before - 1].mode === "title") before -= 1
|
||||
warnings.push({
|
||||
type: "paymentQrOwnPage",
|
||||
page: qr.page,
|
||||
message: `Der Überweisungs-QR-Code steht allein auf Seite ${qr.page}, weil Positionen oder Summen auf Seite ${qr.page - 1} tiefer als ${round(qr.topMm)} mm reichen. Ein früherer Seitenumbruch hält ihn bei den Summen.`,
|
||||
insertPagebreakBeforeIndex: before > 0 ? previous.rows[before].index : layout.rows.length,
|
||||
})
|
||||
}
|
||||
|
||||
for (const [name, block] of [["Summenblock", layout.totals], ["Schlusstext", layout.endText]] as const) {
|
||||
|
||||
@@ -163,8 +163,14 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
- paymentQrLayout.sizeMm * mm
|
||||
}
|
||||
|
||||
// Top edge (mm from the top) of the QR label at its fixed bottom-right spot.
|
||||
const getPaymentQrTopMm = (page: any) => page.getHeight() / mm
|
||||
- (paymentQrLayout.bottomMarginMm + paymentQrLayout.sizeMm + paymentQrLayout.labelGapMm) - 8 / mm
|
||||
// Set once the amounts are placed: the QR moves to its own page if it would cover them.
|
||||
let paymentQrOnNewPage = false
|
||||
|
||||
const getTextWidthBeforePaymentQr = (page: any, startX: number) => {
|
||||
if (!paymentQrCode) return 500
|
||||
if (!paymentQrCode || paymentQrOnNewPage) return 500
|
||||
|
||||
return Math.max(
|
||||
240,
|
||||
@@ -902,6 +908,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
|
||||
let endTextDiff = 35
|
||||
const layoutContentEnd = rowHeight
|
||||
// Visible bottom of the last row (rowHeight is the next baseline); totals extend it below.
|
||||
let amountsBottomMm = rowHeight - 3
|
||||
|
||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
pages[pageCounter - 1].drawLine({
|
||||
@@ -1016,6 +1024,11 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
|
||||
}
|
||||
|
||||
// Totals are baselines 8 mm apart; tax notes (13b/19/12.3) add one more line below.
|
||||
const totalLines = invoiceData.totalArray.length + (["13b UStG", "19 UStG", "12.3 UStG"].includes(invoiceData.taxType) ? 1 : 0)
|
||||
amountsBottomMm = rowHeight + (totalLines - 1) * 8 + 1
|
||||
paymentQrOnNewPage = Boolean(paymentQrCode) && amountsBottomMm + 2 > getPaymentQrTopMm(pages[pageCounter - 1])
|
||||
|
||||
const endTextPosition = getCoordinatesForPDFLib(21, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8, page1)
|
||||
|
||||
if (layout) {
|
||||
@@ -1023,9 +1036,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
const lines = String(invoiceData.endText || "").split("\n")
|
||||
.reduce((sum, line) => sum + Math.max(1, Math.ceil(font.widthOfTextAtSize(line, 10) / maxWidth)), 0)
|
||||
const topMm = rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8
|
||||
// Totals are baselines 8 mm apart; tax notes (13b/19/12.3) add one more line below.
|
||||
const totalLines = invoiceData.totalArray.length + (["13b UStG", "19 UStG", "12.3 UStG"].includes(invoiceData.taxType) ? 1 : 0)
|
||||
layout.totals = { page: pageCounter, topMm: layoutContentEnd, bottomMm: rowHeight + (totalLines - 1) * 8 + 1 }
|
||||
layout.totals = { page: pageCounter, topMm: layoutContentEnd, bottomMm: amountsBottomMm }
|
||||
// drawText places the baseline of the first line; 10 pt line height ≈ 3.53 mm.
|
||||
layout.endText = { page: pageCounter, topMm: topMm - 3.53, bottomMm: topMm + (lines - 1) * 3.53 + 1 }
|
||||
}
|
||||
@@ -1041,6 +1052,16 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
}
|
||||
|
||||
if (paymentQrCode) {
|
||||
// Invoice-like documents decide this before placing the end text.
|
||||
if (deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
paymentQrOnNewPage = amountsBottomMm + 2 > getPaymentQrTopMm(pages[pages.length - 1])
|
||||
}
|
||||
if (paymentQrOnNewPage) {
|
||||
// Positions or totals reach the QR area; never draw the code over amounts.
|
||||
const qrPage = pdfDoc.addPage()
|
||||
qrPage.drawPage(secondPageBackground, { x: 0, y: 0 })
|
||||
pages.push(qrPage)
|
||||
}
|
||||
const lastPage = pages[pages.length - 1]
|
||||
const qrSize = paymentQrLayout.sizeMm * mm
|
||||
const bottomMargin = paymentQrLayout.bottomMarginMm * mm
|
||||
|
||||
@@ -77,7 +77,9 @@ test("rendered preview records rows per page and rasterizes pages", { skip: !has
|
||||
const data = buildOutgoingDocumentPdfData(document(rows), context)
|
||||
const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 }
|
||||
const pdf: any = await createInvoicePDF({} as any, "base64", data, "letterhead.pdf", layout)
|
||||
assert.equal(layout.pageCount, 2)
|
||||
// Rows overflow page 2, so the payment QR code gets an extra third page.
|
||||
assert.equal(layout.pageCount, 3)
|
||||
assert.equal(layout.paymentQr!.page, 3)
|
||||
assert.equal(layout.rows.find((row) => row.index === 13)!.page, 2)
|
||||
assert.ok(layout.endText && layout.totals)
|
||||
const analysis = analyzeOutgoingDocumentLayout(layout)
|
||||
@@ -94,3 +96,30 @@ test("rendered preview records rows per page and rasterizes pages", { skip: !has
|
||||
test("preview tool is read-only for outgoing documents", () => {
|
||||
assert.deepEqual(mcpToolMap.get("accounting.outgoing_documents.preview")!.requiredPermissions, ["accounting.outgoing_documents.read"])
|
||||
})
|
||||
|
||||
test("payment QR code moves to its own page instead of covering amounts", { skip: !hasPdftoppm && "pdftoppm fehlt" }, async (t) => {
|
||||
const letterhead = await PDFDocument.create()
|
||||
letterhead.addPage([595.28, 841.89]).drawText("Briefpapier", { x: 40, y: 40, size: 8 })
|
||||
const background = Buffer.from(await letterhead.save())
|
||||
await initS3()
|
||||
t.mock.method(s3, "send", async () => ({ Body: Readable.from([background]) }))
|
||||
const render = async (count: number) => {
|
||||
const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 }
|
||||
await createInvoicePDF({} as any, "base64", buildOutgoingDocumentPdfData(document(positions(count)), context), "letterhead.pdf", layout)
|
||||
return layout
|
||||
}
|
||||
|
||||
const short = await render(3)
|
||||
assert.equal(short.pageCount, 1)
|
||||
assert.equal(short.paymentQr!.page, 1)
|
||||
assert.ok(short.totals!.bottomMm < short.paymentQr!.topMm)
|
||||
|
||||
const long = await render(6)
|
||||
assert.equal(long.pageCount, 2)
|
||||
assert.equal(long.paymentQr!.page, 2)
|
||||
assert.ok(long.rows.every((row) => row.page === 1))
|
||||
const analysis = analyzeOutgoingDocumentLayout(long)
|
||||
const warning = analysis.warnings.find((item) => item.type === "paymentQrOwnPage")
|
||||
assert.ok(warning, JSON.stringify(analysis.warnings))
|
||||
assert.ok(!analysis.warnings.some((item) => item.type === "emptyPage"))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user