Files
Modules/Jobs/ZUGFeRD/XRechnungViewDocument.vb
2026-07-01 12:22:59 +02:00

1258 lines
58 KiB
VB.net
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Imports System.Collections.Generic
Imports System.Data
Imports System.IO
Imports DigitalData.Modules.Base
Imports DigitalData.Modules.Database
Imports DigitalData.Modules.Logging
Imports GdPicture14
Imports System.Drawing
Imports System.Linq
Imports System.Text.RegularExpressions
'11.11.2025
Public Class XRechnungViewDocument
Private ReadOnly _logger As Logger
Private ReadOnly _logConfig As LogConfig
Private ReadOnly _filesystem As FilesystemEx
Private ReadOnly _file As ZUGFeRD.FileFunctions
Private ReadOnly _gdpictureLicenseKey As String
Private fontResName As String
Private fontResNameBold As String
Private fontResNameItalic As String
' Layout-Konstanten
Private Const MARGIN_LEFT As Integer = 10
Private Const MARGIN_TOP As Integer = 15
Private Const LINE_WIDTH As Integer = 200
Private Const PAGE_HEIGHT_LIMIT As Integer = 278 ' ← war fälschlich auf 275 zurückgesetzt: bei 275 bleiben
' bis zu 2 Zeilen (10mm) ungenutzter Platz vor dem Footer
' (FOOTER_Y=280), wodurch Inhalt unnötig auf die nächste
' Seite umbricht, obwohl noch Platz vorhanden wäre.
Private Const FOOTER_Y As Integer = 280
Private Const FOOTER_TEXT_Y As Integer = 285
Private Const LINE_HEIGHT As Integer = 5
' Spalten-Positionen für Tabellen
Private Const COL_POS_NUMBER As Integer = 10
Private Const COL_POS_AMOUNT As Integer = 19
Private Const COL_POS_UNIT As Integer = 35
Private Const COL_POS_TEXT As Integer = 50
Private Const COL_POS_REASON As Integer = 20
Private Const COL_POS_TAX As Integer = 163
Private Const COL_POS_SUM As Integer = 181
Private Const COL_VALUE_X As Integer = 70
' Spalten für TAXPOS-Tabelle (Steuerbeträge):
' BT 116 <TAB> 2.116,36 € (BASEAMOUNT-Zeile)
' BT 119 <TAB> 7.00 % <TAB> 148,14 € <TAB> VAT (RATE-Zeile)
Private Const COL_TAXPOS_LABEL As Integer = MARGIN_LEFT ' "BT 116" / "BT 119"
Private Const COL_TAXPOS_VALUE1 As Integer = 50 ' Basisbetrag ODER Steuersatz (%)
Private Const COL_TAXPOS_AMOUNT As Integer = 100 ' Steuerbetrag (nur RATE-Zeile)
Private Const COL_TAXPOS_TYPE As Integer = 150 ' VAT-Kennzeichen (nur RATE-Zeile)
' Text-Größen
Private Const TEXT_SIZE_TITLE As Integer = 18
Private Const TEXT_SIZE_NORMAL As Integer = 10
' Text-Längen für Umbruch
Private Const MAX_TEXT_LENGTH_FULL As Integer = 112
Private Const MAX_TEXT_LENGTH_POSITION As Integer = 64
Private Const MAX_TEXT_LENGTH_NOTE As Integer = 70
Public Sub New(LogConfig As LogConfig, MSSQL As MSSQLServer, GDPictureLicenseKey As String)
_logConfig = LogConfig
_logger = LogConfig.GetLogger()
_filesystem = New FilesystemEx(_logConfig)
_file = New ZUGFeRD.FileFunctions(LogConfig, MSSQL)
_gdpictureLicenseKey = GDPictureLicenseKey
End Sub
Public Function Create_PDFfromXML(pXmlFile As FileInfo, pDTItemValues As DataTable) As FileInfo
_logger.Debug("Create_PDFfromXML() Start")
Try
' 1. Initialisierung der Dateipfade
Dim paths As FilePaths = InitializeFilePaths(pXmlFile)
If paths Is Nothing Then Return Nothing
' 2. PDF erstellen und konfigurieren
Dim pdfDoc As GdPicturePDF = InitializePDF()
If pdfDoc Is Nothing Then Return Nothing
' 3. Rendering-Context erstellen
Dim context As New PdfRenderContext(pdfDoc, paths.CreatedString)
' 4. Erste Seite mit Header/Footer
CreateNewPage(context)
' 5. Daten verarbeiten und rendern
ProcessInvoiceData(context, pDTItemValues)
' 6. XML einbetten und speichern
Return FinalizePDF(pdfDoc, paths)
Catch ex As Exception
_logger.Error(ex)
Return Nothing
End Try
End Function
#Region "Initialisierung"
Private Function InitializeFilePaths(xmlFile As FileInfo) As FilePaths
Try
Dim xmlPath As String = xmlFile.FullName
Dim tempPath As String = Path.Combine(Path.GetDirectoryName(xmlPath), "temp")
' Temp-Verzeichnis erstellen
If Not Directory.Exists(tempPath) Then
Directory.CreateDirectory(tempPath)
End If
Dim tempXmlPath As String = Path.Combine(tempPath, "xrechnung.xml")
If File.Exists(tempXmlPath) Then
File.Delete(tempXmlPath)
End If
Dim pdfFilename As String = Regex.Replace(xmlFile.Name, ".xml", ".pdf", RegexOptions.IgnoreCase)
Dim outputPath As String = Path.Combine(Path.GetDirectoryName(xmlPath), pdfFilename)
If File.Exists(outputPath) Then
File.Delete(outputPath)
End If
Dim paths As New FilePaths With {
.XmlPath = xmlPath,
.TempPath = tempPath,
.TempXmlPath = tempXmlPath,
.PdfFilename = pdfFilename,
.OutputPath = outputPath,
.CreatedString = $"Maschinell erstellt durch / Automatically created by Digital Data E-Rechnung Parser: {Now.ToString}"
}
_logger.Debug("Create_PDFfromXML() Resulting PDF Filepath: [{0}]", paths.OutputPath)
Return paths
Catch ex As Exception
_logger.Error("Error initializing file paths", ex)
Return Nothing
End Try
End Function
Private Function InitializePDF() As GdPicturePDF
Try
Dim licManager As New LicenseManager()
licManager.RegisterKEY(_gdpictureLicenseKey)
Dim pdf As New GdPicturePDF()
Dim status As GdPictureStatus = pdf.NewPDF(PdfConformance.PDF_A_3a)
If status <> GdPictureStatus.OK Then
_logger.Warn($"Error initializing PDF: {status}")
Return Nothing
End If
' PDF-Einstellungen
pdf.SetOrigin(PdfOrigin.PdfOriginTopLeft)
pdf.SetMeasurementUnit(PdfMeasurementUnit.PdfMeasurementUnitMillimeter)
pdf.SetLineWidth(1)
pdf.SetTitle("xInvoice VisualReceipt")
pdf.SetAuthor("Digital Data GmbH, Ludwig Rinn Str. 16, 35452 Heuchelheim")
' Fonts initialisieren
fontResName = pdf.AddStandardFont(PdfStandardFont.PdfStandardFontHelvetica)
fontResNameBold = pdf.AddStandardFont(PdfStandardFont.PdfStandardFontHelveticaBold)
fontResNameItalic = pdf.AddStandardFont(PdfStandardFont.PdfStandardFontHelveticaBoldOblique)
Return pdf
Catch ex As Exception
_logger.Error("Error initializing PDF", ex)
Return Nothing
End Try
End Function
#End Region
#Region "Seiten-Management"
Private Sub CreateNewPage(context As PdfRenderContext)
Dim status As GdPictureStatus = context.PDF.NewPage(PdfPageSizes.PdfPageSizeA4)
If status <> GdPictureStatus.OK Then
Throw New Exception($"Could not create page: {status}")
End If
DrawHeader(context.PDF)
DrawFooter(context.PDF, context.CreatedString)
context.YPosition = MARGIN_TOP + 30 ' Nach Header
' WICHTIG: CurrentPositionPage wird hier NICHT gesetzt.
' Es gehört zum Positions-Anker und wird nur in HandleArticleTextFollowUp
' und HandlePositionAmountFollowUp gesetzt nicht beim Seitenbruch selbst.
End Sub
Private Sub DrawHeader(pdf As GdPicturePDF)
Dim y As Integer = MARGIN_TOP
pdf.SetTextSize(TEXT_SIZE_TITLE)
pdf.DrawText(fontResName, MARGIN_LEFT, y, "xRechnung Sichtbeleg - xInvoice Visual Receipt")
y += 10
pdf.SetTextSize(TEXT_SIZE_NORMAL)
pdf.DrawText(fontResNameItalic, MARGIN_LEFT, y, XRechnungStrings.CommentSichtbeleg_DE_Row1)
y += LINE_HEIGHT
pdf.DrawText(fontResNameItalic, MARGIN_LEFT, y, XRechnungStrings.CommentSichtbeleg_DE_Row2)
y += LINE_HEIGHT
pdf.DrawText(fontResNameItalic, MARGIN_LEFT, y, XRechnungStrings.CommentSichtbeleg_EN_Row1)
y += LINE_HEIGHT
pdf.DrawText(fontResNameItalic, MARGIN_LEFT, y, XRechnungStrings.CommentSichtbeleg_EN_Row2)
End Sub
Private Sub DrawFooter(pdf As GdPicturePDF, createdString As String)
pdf.DrawLine(MARGIN_LEFT, FOOTER_Y, LINE_WIDTH, FOOTER_Y)
pdf.DrawText(fontResName, MARGIN_LEFT, FOOTER_TEXT_Y, createdString)
End Sub
Private Sub CheckAndCreateNewPageIfNeeded(context As PdfRenderContext)
' ← NEU: Prüfe BEIDE Positionen
Dim maxY As Integer = Math.Max(context.YPosition, context.YDynamic)
If maxY >= PAGE_HEIGHT_LIMIT Then
Dim status As GdPictureStatus = context.PDF.NewPage(PdfPageSizes.PdfPageSizeA4)
If status <> GdPictureStatus.OK Then
_logger.Warn($"Could not create a second page. The error was: {status}")
Throw New Exception($"Could not create page: {status}")
End If
DrawHeader(context.PDF)
DrawFooter(context.PDF, context.CreatedString)
context.YPosition = MARGIN_TOP + 30
context.YDynamic = context.YPosition ' ← WICHTIG: Reset YDynamic!
End If
End Sub
#End Region
#Region "Datenverarbeitung"
Private Sub ProcessInvoiceData(context As PdfRenderContext, dataTable As DataTable)
Dim formerItemSpecName As String = ""
For Each oRow As DataRow In dataTable.Rows
' Prüfen ob neue Seite benötigt wird
CheckAndCreateNewPageIfNeeded(context)
Dim itemData As New InvoiceItemData(oRow)
' Interne Zeilen behandeln
If itemData.NormalizedArea = "INTERNAL" Then
HandleInternalRow(context, itemData)
Continue For
End If
_logger.Debug($"WorkingItem: Area=[{itemData.NormalizedArea}] SpecName=[{itemData.SpecName}] Value=[{itemData.Value}] Caption=[{itemData.Caption}] Display=[{itemData.Display}] LastRow=[{itemData.IsLastRowSameArea}]")
' WICHTIG: Area-Switch-Flag VORHER setzen!
Dim isAreaSwitch As Boolean = (context.CurrentArea <> itemData.NormalizedArea)
' Area-Wechsel behandeln
If isAreaSwitch Then
formerItemSpecName = "" ' Reset bei Area-Wechsel
HandleAreaSwitch(context, itemData)
Else
HandleFollowUpItem(context, itemData, formerItemSpecName)
End If
' Item anzeigen - MIT Area-Switch-Info übergeben!
If itemData.Display AndAlso Not String.IsNullOrEmpty(itemData.Value) Then
RenderDisplayItem(context, itemData, isAreaSwitch) ' ← Parameter hinzugefügt!
End If
Next
End Sub
Private Sub HandleInternalRow(context As PdfRenderContext, itemData As InvoiceItemData)
_logger.Debug("Next Item as Area is internal")
If itemData.SpecName = "STATIC_Y_SWITCH" Then
context.YPosition = CInt(itemData.Value)
End If
End Sub
#End Region
#Region "Area-Switch Handling"
Private Sub HandleAreaSwitch(context As PdfRenderContext, itemData As InvoiceItemData)
context.CurrentArea = itemData.NormalizedArea
context.CreateTextBox = False
_logger.Debug($"Area-Switch to: {context.CurrentArea}")
' Area-Header zeichnen - ÜBERGIBT itemData komplett
Dim areaCaption As String = GetAreaCaption(context, itemData)
If Not String.IsNullOrEmpty(areaCaption) Then
' ← NEU: VORHER prüfen ob genug Platz für Header + minimale Inhalte (3 Zeilen = 15mm)
Dim requiredSpace As Integer = 15 ' Header (3x LINE_HEIGHT) + mindestens 1 Inhalt-Zeile
If (context.YPosition + requiredSpace) >= PAGE_HEIGHT_LIMIT Then
CreateNewPage(context)
context.YDynamic = 0 ' Seitenbruch: YDynamic-Korrektur unten darf nicht greifen
_logger.Debug($"Area-Switch [{context.CurrentArea}]: Page break BEFORE header! New YPosition: {context.YPosition}")
End If
' *** BUGFIX: Leerzeile vor Area-Header bei POSITION/DESCRIPTION-Übergang ***
'
' DrawAreaHeader zeichnet die Trennlinie bei: context.YPosition + LINE_HEIGHT.
' Nach Areas die RenderMultiLineText verwenden (POSITION, ALLOWANCE), hat
' HandleArticleTextFollowUp YPosition bereits auf YDynamic gesetzt das ist
' eine Zeile NACH dem letzten gerenderten Text (so arbeitet RenderMultiLineText).
' DrawAreaHeader addiert nochmals +LINE_HEIGHT → insgesamt 2 Zeilen Abstand
' statt der gewünschten 1 Zeile (= Trennlinie direkt nach dem letzten Inhalt).
'
' Erkennungsmerkmal: context.YDynamic > 0 bedeutet, der vorige Bereich hat
' YPosition über den YDynamic-Sync bereits um 1 LINE_HEIGHT vorgerückt.
' Korrektur: 1 LINE_HEIGHT wieder abziehen, damit DrawAreaHeader den richtigen
' Abstand erzeugt. YDynamic selbst wird NICHT zurückgesetzt, damit
' yPositionAlreadyAdvanced in HandlePositionAmountFollowUp weiterhin korrekt
' erkennen kann, dass kein weiterer Vorschub nötig ist.
If context.YDynamic > 0 Then
context.YPosition -= LINE_HEIGHT
End If
DrawAreaHeader(context, areaCaption)
DrawAreaSpecificHeaders(context)
End If
' Area-spezifische Initialisierung - VERWENDET context.CurrentArea (normalisiert)
Select Case context.CurrentArea
Case "TYPE"
HandleTypeAreaSwitch(context, itemData)
Case "POSITION"
HandlePositionAreaSwitch(context, itemData)
Case "ALLOWANCE"
HandleAllowanceAreaSwitch(context, itemData)
Case "INCL_NOTE"
HandleIncludedNoteAreaSwitch(context, itemData)
Case "TAXPOS"
HandleTaxPosAreaSwitch(context, itemData)
Case "AMOUNT"
context.CreateTextBox = True
End Select
End Sub
Private Function GetAreaCaption(context As PdfRenderContext, itemData As InvoiceItemData) As String
' VERWENDET NormalizedArea für Switch, aber AreaCaptionFromPattern für INCL_NOTE
Select Case itemData.NormalizedArea
Case "TYPE"
Return $"{Return_InvType(itemData.Value)} [{itemData.Value}]"
Case "SELLER"
Return "Verkäufer / Seller:"
Case "BUYER"
Return "Käufer / Buyer:"
Case "HEAD"
Return String.Empty
Case "POSITION"
Return "Positionen / Positions:"
Case "INCL_NOTE"
' Verwendet den Caption aus dem Pattern INCL_NOTE#...
If Not String.IsNullOrEmpty(itemData.AreaCaptionFromPattern) Then
Return itemData.AreaCaptionFromPattern
End If
' Fallback wenn kein Pattern vorhanden
Return "Notizen und Hinweise / Notes:"
Case "ALLOWANCE"
Return GetAllowanceCaption(context, itemData)
Case "AMOUNT"
Return "Beträge / Amounts:"
Case "TAXPOS"
Return "Steuerbeträge / Tax amounts:"
Case "PAYMENT"
Return "Zahlungsinformationen / Payment details:"
Case "EXEMPTION"
Return "UST.-Befreiungsgrund / Exemption reason:"
Case Else
Return String.Empty
End Select
End Function
Private Function GetAllowanceCaption(context As PdfRenderContext, itemData As InvoiceItemData) As String
If itemData.SpecName = "RECEIPT_ALLOWANCE_CHARGE_INDICATOR" Then
If itemData.Value = "False" Then
context.HasDiscount = True
Return "Rabatt/Discount:"
Else
Return "Zuschlag/Surcharge:"
End If
End If
Return "Zu- oder Abschlag/Surcharge or Discount:"
End Function
Private Sub DrawAreaHeader(context As PdfRenderContext, caption As String)
context.YPosition += LINE_HEIGHT
_logger.Debug($"Drawing area header for [{context.CurrentArea}] with caption [{caption}] at YPosition={context.YPosition}")
context.PDF.DrawLine(MARGIN_LEFT, context.YPosition, LINE_WIDTH, context.YPosition)
context.YPosition += LINE_HEIGHT
context.PDF.DrawText(fontResNameBold, MARGIN_LEFT, context.YPosition, caption)
context.YPosition += LINE_HEIGHT
If context.CurrentArea = "TYPE" Then
context.PDF.DrawLine(MARGIN_LEFT, context.YPosition, LINE_WIDTH, context.YPosition)
context.YPosition += LINE_HEIGHT
End If
End Sub
Private Sub DrawAreaSpecificHeaders(context As PdfRenderContext)
'VERWENDET context.CurrentArea (bereits normalisiert)
Select Case context.CurrentArea
Case "POSITION"
DrawPositionTableHeader(context)
Case "INCL_NOTE"
' Kein spezifischer Header für INCL_NOTE mehr nötig
' Der Caption kommt bereits aus GetAreaCaption
Case "ALLOWANCE" ' ← KORRIGIERT!
DrawAllowanceTableHeader(context)
End Select
End Sub
Private Sub DrawPositionTableHeader(context As PdfRenderContext)
context.PDF.DrawText(fontResName, COL_POS_NUMBER, context.YPosition, "Pos#")
context.PDF.DrawText(fontResName, COL_POS_AMOUNT, context.YPosition, "Anz./am.")
context.PDF.DrawText(fontResName, COL_POS_UNIT, context.YPosition, "Einh/Unit")
context.PDF.DrawText(fontResName, COL_POS_TEXT, context.YPosition, "Pos.Text")
context.PDF.DrawText(fontResName, COL_POS_TAX, context.YPosition, "Steuer/Tax")
context.PDF.DrawText(fontResName, COL_POS_SUM, context.YPosition, "Betrag/Sum")
End Sub
Private Sub DrawAllowanceTableHeader(context As PdfRenderContext)
context.PDF.DrawText(fontResName, COL_POS_NUMBER, context.YPosition, "Pos#")
context.PDF.DrawText(fontResName, COL_POS_REASON, context.YPosition, "Grund/Reason")
context.PDF.DrawText(fontResName, COL_POS_TAX, context.YPosition, "Steuer/Tax")
context.PDF.DrawText(fontResName, COL_POS_SUM, context.YPosition, "Betrag/Sum")
context.PositionCount = 0
End Sub
#End Region
#Region "Area-Specific Switch Handlers"
Private Sub HandleTypeAreaSwitch(context As PdfRenderContext, itemData As InvoiceItemData)
If itemData.SpecName = "INVOICE_CURRENCY" Then
If itemData.Value <> "EUR" Then
context.CurrencySymbol = itemData.Value
End If
End If
End Sub
Private Sub HandlePositionAreaSwitch(context As PdfRenderContext, itemData As InvoiceItemData)
If itemData.SpecName = "INVOICE_POSITION_AMOUNT" Then
' KEIN Increment hier - wird in Follow-Up gemacht
context.YDynamic = 0
context.YPosition += LINE_HEIGHT ' Neue Zeile für erste Position
context.CurrentPositionStartY = context.YPosition
context.CurrentPositionPage = context.PDF.GetPageCount()
context.PDF.DrawText(fontResName, COL_POS_NUMBER, context.YPosition, "1") ' Erste Position ist immer 1
context.PDF.DrawText(fontResName, COL_POS_AMOUNT, context.YPosition, itemData.Value)
context.PositionCount = 1 ' Zähler auf 1 setzen statt increment
itemData.Display = False
End If
End Sub
Private Sub HandleAllowanceAreaSwitch(context As PdfRenderContext, itemData As InvoiceItemData)
' Erste Allowance: Prüfe ob es CHARGE_INDICATOR (Metadata) oder ACTUAL_AMOUNT (erste Zeile) ist
If itemData.SpecName = "RECEIPT_ALLOWANCE_CHARGE_INDICATOR" Then
' Nur Metadata - nichts rendern, nur Flag setzen
_logger.Debug($"Found RECEIPT_ALLOWANCE_CHARGE_INDICATOR with value [{itemData.Value}]. Setting HasDiscount={itemData.Value = "False"} in context.")
itemData.Display = False
ElseIf {"RECEIPT_ALLOWANCE_ACTUAL_AMOUNT", "POSITION_ALLOWANCE_ACTUAL_AMOUNT"}.Contains(itemData.SpecName) Then
' Erste Allowance-Zeile
context.YPosition += LINE_HEIGHT
context.PDF.DrawText(fontResName, COL_POS_NUMBER, context.YPosition, "1")
Dim currTerm As String = FormatCurrency(itemData.Value, context.CurrencySymbol)
If context.HasDiscount AndAlso itemData.SpecName = "RECEIPT_ALLOWANCE_ACTUAL_AMOUNT" AndAlso Not currTerm.StartsWith("-") Then
currTerm = "-" + currTerm
End If
context.PDF.DrawText(fontResName, COL_POS_SUM, context.YPosition, currTerm) ' ← In COL_POS_SUM!
context.PositionCount = 1
itemData.Display = False
_logger.Debug($"First Allowance/Charge rendered with amount [{currTerm}]. PositionCount set to 1. HasDiscount={context.HasDiscount}. Display set to False.")
End If
End Sub
Private Sub HandleIncludedNoteAreaSwitch(context As PdfRenderContext, itemData As InvoiceItemData)
' Erste Note direkt ausgeben
context.PDF.DrawText(fontResName, MARGIN_LEFT, context.YPosition, itemData.Value)
itemData.Display = False
End Sub
Private Sub HandleTaxPosAreaSwitch(context As PdfRenderContext, itemData As InvoiceItemData)
' Die TAXPOS-Area kann pro Steuersatz-Gruppe mit BASEAMOUNT ODER RATE beginnen:
' Gruppe: [BASEAMOUNT] RATE AMOUNT TYPE (BASEAMOUNT optional, ggf. nicht vorhanden)
' Jede Gruppe ergibt GENAU EINE Zeile, sobald TYPE (VAT) eintrifft - außer der
' optionalen BASEAMOUNT-Zeile, die SOFORT und SEPARAT gerendert wird.
' WICHTIG: Dies ist die allererste Zeile der Area - kein zusätzlicher Zeilenvorschub.
context.IsFirstTaxPosDisplay = True
If itemData.SpecName = "INVOICE_TAXPOS_BASEAMOUNT" Then
RenderTaxposBaseAmountRow(context, itemData)
itemData.Display = False
ElseIf itemData.SpecName = "INVOICE_TAXPOS_RATE" Then
context.TaxPosRate = itemData.Value
context.TaxPosRateCaption = itemData.Caption
itemData.Display = False
_logger.Debug($"TAXPOS RATE in AreaSwitch (group start, no BASEAMOUNT) accumulated: [{context.TaxPosRate}]")
End If
End Sub
''' <summary>
''' Rendert die Basisbetrag-Zeile sofort in eigener Zeile: "BT 116 <TAB> 2.116,36 €"
''' Diese Zeile ist unabhängig von der nachfolgenden RATE/AMOUNT/TYPE-Zeile.
''' </summary>
Private Sub RenderTaxposBaseAmountRow(context As PdfRenderContext, itemData As InvoiceItemData)
' Erste Zeile der TAXPOS-Area braucht keinen zusätzlichen Zeilenvorschub
' (Area-Header hat YPosition bereits korrekt positioniert); alle weiteren Zeilen schon.
If Not context.IsFirstTaxPosDisplay Then
context.YPosition += LINE_HEIGHT
End If
context.IsFirstTaxPosDisplay = False
Dim baseAmountFormatted As String = FormatCurrency(itemData.Value, context.CurrencySymbol)
context.PDF.DrawText(fontResName, COL_TAXPOS_LABEL, context.YPosition, itemData.Caption)
context.PDF.DrawText(fontResName, COL_TAXPOS_VALUE1, context.YPosition, baseAmountFormatted)
_logger.Debug($"TAXPOS BASEAMOUNT row rendered: [{itemData.Caption}] [{baseAmountFormatted}] at Y={context.YPosition}")
End Sub
#End Region
#Region "Follow-Up Item Handling"
Private Sub HandleFollowUpItem(context As PdfRenderContext, itemData As InvoiceItemData, ByRef formerItemSpecName As String)
_logger.Debug($"FollowItem START - CurrentArea: [{context.CurrentArea}] - ItemArea: [{itemData.NormalizedArea}] - SpecName: [{itemData.SpecName}] - Value: [{itemData.Value}] - YPos: [{context.YPosition}]")
Dim descriptionFollowup As Boolean = False
Select Case context.CurrentArea
Case "POSITION", "ALLOWANCE"
_logger.Debug($"FollowItem: Entering POSITION/ALLOWANCE handler")
HandlePositionOrAllowanceFollowUp(context, itemData, formerItemSpecName, descriptionFollowup)
Case "HEAD"
_logger.Debug($"FollowItem: Entering HEAD handler")
HandleHeadFollowUp(itemData)
Case "TAXPOS"
_logger.Debug($"FollowItem: Entering TAXPOS handler")
HandleTaxPosFollowUp(context, itemData)
Case "INCL_NOTE"
_logger.Debug($"FollowItem: Entering INCL_NOTE handler - YPosition before={context.YPosition}")
context.YPosition += LINE_HEIGHT
context.PDF.DrawText(fontResName, MARGIN_LEFT, context.YPosition, itemData.Value)
itemData.Display = False
_logger.Debug($"FollowItem: INCL_NOTE handler - YPosition after={context.YPosition}, Display set to False")
Case Else
_logger.Warn($"FollowItem: UNHANDLED CurrentArea=[{context.CurrentArea}] for SpecName=[{itemData.SpecName}]")
End Select
_logger.Debug($"FollowItem END - CurrentArea: [{context.CurrentArea}] - YPos: [{context.YPosition}] - Display: [{itemData.Display}]")
End Sub
Private Sub HandlePositionOrAllowanceFollowUp(context As PdfRenderContext, itemData As InvoiceItemData, ByRef formerItemSpecName As String, ByRef descriptionFollowup As Boolean)
' Former Item Tracking
If itemData.SpecName <> formerItemSpecName AndAlso formerItemSpecName <> "" Then
If itemData.SpecName = "INVOICE_POSITION_ARTICLE_DESCRIPTION" AndAlso formerItemSpecName = "INVOICE_POSITION_ARTICLE" Then
descriptionFollowup = True
Else
formerItemSpecName = itemData.SpecName
End If
Else
formerItemSpecName = itemData.SpecName
End If
' Spezifische Item-Behandlung
If {"INVOICE_POSITION_AMOUNT", "POSITION_ALLOWANCE_ACTUAL_AMOUNT", "RECEIPT_ALLOWANCE_ACTUAL_AMOUNT"}.Contains(itemData.SpecName) Then
HandlePositionAmountFollowUp(context, itemData, descriptionFollowup)
ElseIf itemData.SpecName = "INVOICE_POSITION_UNIT_TYPE" Then
HandleUnitTypeFollowUp(context, itemData)
ElseIf {"POSITION_ALLOWANCE_REASON", "RECEIPT_ALLOWANCE_REASON"}.Contains(itemData.SpecName) Then
context.PDF.DrawText(fontResName, COL_POS_REASON, context.YPosition, itemData.Value)
itemData.Display = False
ElseIf {"INVOICE_POSITION_ARTICLE", "INVOICE_POSITION_ARTICLE_DESCRIPTION"}.Contains(itemData.SpecName) Then
HandleArticleTextFollowUp(context, itemData, descriptionFollowup)
ElseIf itemData.SpecName = "INVOICE_POSITION_NOTE" Then
HandlePositionNoteFollowUp(context, itemData)
ElseIf {"INVOICE_TAXPOS_TAX_RATE", "INVOICE_TAXPOS_RATE"}.Contains(itemData.SpecName) Then
HandleTaxRateFollowUp(context, itemData)
ElseIf {"RECEIPT_ALLOWANCE_VAT_RATE", "POSITION_ALLOWANCE_VAT_RATE"}.Contains(itemData.SpecName) Then
HandleTaxRateFollowUp(context, itemData)
ElseIf itemData.SpecName = "INVOICE_POSITION_TAX_AMOUNT" Then
HandlePositionTaxAmountFollowUp(context, itemData)
ElseIf {"RECEIPT_ALLOWANCE_VAT_CODE", "POSITION_ALLOWANCE_VAT_CODE"}.Contains(itemData.SpecName) Then
itemData.Display = False
ElseIf {"RECEIPT_ALLOWANCE_CALCULATION_PERCENT", "POSITION_ALLOWANCE_CALCULATION_PERCENT"}.Contains(itemData.SpecName) Then
itemData.Display = False
ElseIf itemData.SpecName = "RECEIPT_ALLOWANCE_CHARGE_INDICATOR" Then
itemData.Display = False
End If
End Sub
Private Sub HandlePositionAmountFollowUp(context As PdfRenderContext, itemData As InvoiceItemData, descriptionFollowup As Boolean)
context.PositionCount += 1
' *** BUGFIX: Unnötige Leerzeile zwischen Positionen ***
'
' HandleArticleTextFollowUp synct am Ende jeder Position:
' context.YPosition = Math.Max(context.YPosition, context.YDynamic)
' Nach einer mehrzeiligen DESCRIPTION (z.B. 2 Teile) zeigt YDynamic bereits auf die
' NÄCHSTE freie Zeile (RenderMultiLineText erhöht YDynamic nach jedem Teil). D.h.
' YPosition steht zu Beginn dieser Methode oft schon korrekt auf der nächsten freien
' Zeile - ein zusätzliches "+= LINE_HEIGHT" weiter unten würde dann eine echte
' Leerzeile zwischen letzter DESCRIPTION-Zeile und der neuen Position erzeugen.
'
' Erkennungsmerkmal: YDynamic > 0 UND YDynamic = YPosition bedeutet, die vorherige
' Position hat bereits über RenderMultiLineText/HandleArticleTextFollowUp die
' YPosition auf die korrekte nächste freie Zeile gesetzt - dann KEIN weiterer Vorschub.
Dim yPositionAlreadyAdvanced As Boolean = (context.YDynamic > 0) AndAlso (context.YDynamic = context.YPosition)
If Not descriptionFollowup Then
context.YPlus = 0
context.YDynamic = 0
End If
If Not yPositionAlreadyAdvanced Then
context.YPosition += LINE_HEIGHT
End If
context.CurrentPositionStartY = context.YPosition
context.CurrentPositionPage = context.PDF.GetPageCount()
' ✓ OPTIMIERT: Von 11mm auf 6mm
' WARUM 6mm?
' - Position#-Zeile: 5mm (wird SOFORT gerendert)
' - Tax/Amount: 0mm Extra (werden auf DERSELBEN Y-Position gerendert via CurrentPositionStartY)
' - Minimaler Puffer: 1mm (für Textbox-Rendering-Toleranzen)
Dim requiredSpace As Integer = 6
If (context.YPosition + requiredSpace) >= PAGE_HEIGHT_LIMIT Then
CreateNewPage(context)
context.YDynamic = context.YPosition
context.CurrentPositionStartY = context.YPosition
context.CurrentPositionPage = context.PDF.GetPageCount() ' neue Seite ist der Anker
_logger.Debug($"HandlePositionAmountFollowUp: Page break! New YPosition={context.YPosition}, CurrentPositionStartY={context.CurrentPositionStartY}, Page={context.CurrentPositionPage}")
End If
context.PDF.DrawText(fontResName, COL_POS_NUMBER, context.YPosition, context.PositionCount.ToString())
If itemData.SpecName = "INVOICE_POSITION_AMOUNT" Then
context.PDF.DrawText(fontResName, COL_POS_AMOUNT, context.YPosition, itemData.Value)
ElseIf {"POSITION_ALLOWANCE_ACTUAL_AMOUNT", "RECEIPT_ALLOWANCE_ACTUAL_AMOUNT"}.Contains(itemData.SpecName) Then
Dim term As String = FormatCurrency(itemData.Value, context.CurrencySymbol)
If context.HasDiscount AndAlso itemData.SpecName = "RECEIPT_ALLOWANCE_ACTUAL_AMOUNT" AndAlso Not term.StartsWith("-") Then
term = "-" + term
End If
context.PDF.DrawText(fontResName, COL_POS_SUM, context.YPosition, term)
Else
If context.YDynamic = 0 Then
context.YDynamic = context.YPosition
End If
RenderMultiLineText(context, itemData.Value, COL_POS_AMOUNT, MAX_TEXT_LENGTH_POSITION)
End If
itemData.Display = False
End Sub
Private Sub HandleUnitTypeFollowUp(context As PdfRenderContext, itemData As InvoiceItemData)
context.YPlus = 0
Dim unit As String = Return_UnitType(itemData.Value)
context.PDF.DrawText(fontResName, COL_POS_UNIT, context.YPosition, unit)
itemData.Display = False
End Sub
Private Sub HandleArticleTextFollowUp(context As PdfRenderContext, itemData As InvoiceItemData, descriptionFollowup As Boolean)
If Not descriptionFollowup Then
context.YPlus = 0
End If
If context.YDynamic = 0 Then
context.YDynamic = context.YPosition
End If
Dim xPos As Integer = COL_POS_TEXT
If itemData.SpecName.Contains("ALLOWANCE") Then
xPos = COL_POS_REASON
End If
' *** BUGFIX: CurrentPositionStartY auf die ERSTE Zeile von INVOICE_POSITION_ARTICLE verankern ***
'
' Tax (COL_POS_TAX) und Amount (COL_POS_SUM) müssen in der ERSTEN Zeile des Artikeltexts
' erscheinen - dort wo der Artikelname und damit auch der Steuersatz/Betrag laut Layout
' hingehören. Bei kurzen (einzeiligen) Artikeln ist "erste Zeile" = "letzte Zeile", daher
' lieferte die alte Berechnung (YDynamic - LINE_HEIGHT, NACH dem Rendern) bislang dasselbe
' Ergebnis. Bei LANGEN, mehrzeiligen Artikeltexten (z.B. 15 Zeilen Prüfbeschreibung, die
' selbst über einen Seitenbruch laufen) zeigte "YDynamic - LINE_HEIGHT NACH dem Rendern"
' auf die LETZTE Zeile des Artikeltexts (ggf. auf einer späteren Seite) - das war falsch.
'
' Die neuen ByRef-Parameter firstAnchorY/firstAnchorPage liefern stattdessen exakt den
' Y-Wert und die Seite des ALLERERSTEN tatsächlich gezeichneten Textteils, erfasst
' INNERHALB von RenderMultiLineText direkt vor dem ersten DrawText-Aufruf - das ist in
' allen Fällen korrekt, auch wenn der Seitenbruch noch VOR dem ersten Zeichnen eintritt
' (Szenario B: Artikel beginnt komplett auf neuer Seite).
'
' Szenario A kein Seitenbruch, kurzer Artikel: erste Zeile=240/Seite1 → Anker=240/1 (wie bisher) ✓
' Szenario B FIRST-part-Break (Artikel beginnt auf neuer Seite): erste Zeile=45/Seite2 → Anker=45/2 (wie bisher) ✓
' Szenario C CONTINUATION-Break in DESCRIPTION (Artikel einzeilig, bleibt auf alter Seite):
' erste Zeile=265/Seite1 → Anker=265/1 (wie bisher, unverändert) ✓
' Szenario D NEU: langer mehrzeiliger ARTIKEL, der selbst über mehrere Seiten läuft:
' erste Zeile=220/Seite1 → Anker=220/1 (KORRIGIERT, vorher fälschlich letzte Zeile/spätere Seite)
If itemData.SpecName = "INVOICE_POSITION_ARTICLE" Then
Dim firstAnchorY As Integer = -1
Dim firstAnchorPage As Integer = -1
RenderMultiLineText(context, itemData.Value, xPos, MAX_TEXT_LENGTH_POSITION, firstAnchorY, firstAnchorPage)
context.CurrentPositionStartY = firstAnchorY
context.CurrentPositionPage = firstAnchorPage
_logger.Debug($"HandleArticleTextFollowUp: CurrentPositionStartY anchored to FIRST ARTICLE row Y={context.CurrentPositionStartY} on page {context.CurrentPositionPage}")
Else
RenderMultiLineText(context, itemData.Value, xPos, MAX_TEXT_LENGTH_POSITION)
End If
' YPosition immer synchronisieren
context.YPosition = Math.Max(context.YPosition, context.YDynamic)
itemData.Display = False
End Sub
Private Sub HandlePositionNoteFollowUp(context As PdfRenderContext, itemData As InvoiceItemData)
If context.YDynamic = 0 Then
context.YDynamic = context.YPosition
End If
RenderMultiLineText(context, itemData.Value, COL_POS_TEXT, MAX_TEXT_LENGTH_NOTE)
' ✓ KORRIGIERT: YPosition synchronisieren
context.YPosition = Math.Max(context.YPosition, context.YDynamic)
itemData.Display = False
End Sub
Private Sub HandleTaxRateFollowUp(context As PdfRenderContext, itemData As InvoiceItemData)
Dim yPos As Integer = context.CurrentPositionStartY
Dim targetPage As Integer = context.CurrentPositionPage
Dim activePage As Integer = context.PDF.GetPageCount()
_logger.Debug($"Handling Tax Rate Follow-Up: Value=[{itemData.Value}] at YPos={yPos}, targetPage={targetPage}, activePage={activePage}")
If yPos < MARGIN_TOP + 30 Then
_logger.Warn($"TaxRate: yPos={yPos} ungültig. Verwende YPosition={context.YPosition}.")
yPos = context.YPosition
targetPage = activePage
End If
If targetPage <> activePage Then
context.PDF.SelectPage(targetPage)
_logger.Debug($"TaxRate: SelectPage({targetPage}) to draw at Y={yPos}, then back to page {activePage}")
End If
' SetTextSize explizit setzen: GdPicturePDF hält Font-Zustand global;
' nach SelectPage ist er undefiniert und muss vor jedem DrawText normalisiert werden.
context.PDF.SetTextSize(TEXT_SIZE_NORMAL)
context.PDF.DrawText(fontResName, COL_POS_TAX, yPos, $"{itemData.Value} %")
If targetPage <> activePage Then
context.PDF.SelectPage(activePage)
context.PDF.SetTextSize(TEXT_SIZE_NORMAL) ' Font-Zustand der aktiven Seite wiederherstellen
End If
itemData.Display = False
End Sub
Private Sub HandlePositionTaxAmountFollowUp(context As PdfRenderContext, itemData As InvoiceItemData)
Dim yPos As Integer = context.CurrentPositionStartY
Dim targetPage As Integer = context.CurrentPositionPage
Dim activePage As Integer = context.PDF.GetPageCount()
If yPos < MARGIN_TOP + 30 Then
_logger.Warn($"TaxAmount: yPos={yPos} ungültig. Verwende YPosition={context.YPosition}.")
yPos = context.YPosition
targetPage = activePage
End If
Dim yPosAdjusted As Double = yPos - 3.5
Dim taxTerm As String = FormatCurrency(itemData.Value, context.CurrencySymbol)
_logger.Debug($"Handling Position Tax Amount Follow-Up: Value=[{itemData.Value}] Formatted=[{taxTerm}] at YPos={yPos}, Adjusted={yPosAdjusted}, targetPage={targetPage}, activePage={activePage}")
If targetPage <> activePage Then
context.PDF.SelectPage(targetPage)
_logger.Debug($"TaxAmount: SelectPage({targetPage}) to draw at Y={yPosAdjusted}, then back to page {activePage}")
End If
' SetTextSize explizit setzen gleicher Grund wie in HandleTaxRateFollowUp.
context.PDF.SetTextSize(TEXT_SIZE_NORMAL)
context.PDF.DrawTextBox(fontResName, 177, yPosAdjusted, 198, YCoo_TextBoxPlus5(yPosAdjusted),
TextAlignment.TextAlignmentFar, TextAlignment.TextAlignmentNear,
taxTerm)
If targetPage <> activePage Then
context.PDF.SelectPage(activePage)
context.PDF.SetTextSize(TEXT_SIZE_NORMAL) ' Font-Zustand der aktiven Seite wiederherstellen
End If
itemData.Display = False
End Sub
Private Sub HandleHeadFollowUp(itemData As InvoiceItemData)
If {"INVOICE_DATE", "INVOICE_SERVICE_DATE"}.Contains(itemData.SpecName) Then
itemData.Value = StringFunctions.DatetimeStringToGermanStringConverter(itemData.Value, _logger)
End If
End Sub
Private Sub HandleTaxPosFollowUp(context As PdfRenderContext, itemData As InvoiceItemData)
If itemData.SpecName = "INVOICE_TAXPOS_BASEAMOUNT" Then
' BASEAMOUNT kann auch als Follow-Up (zweite und weitere Steuersatz-Gruppen)
' auftreten, nicht nur im allerersten Area-Switch. Eigene Zeile, sofort gerendert.
RenderTaxposBaseAmountRow(context, itemData)
itemData.Display = False
ElseIf itemData.SpecName = "INVOICE_TAXPOS_RATE" Then
context.PositionCount += 1
context.TaxPosRate = itemData.Value
context.TaxPosRateCaption = itemData.Caption
itemData.Display = False
_logger.Debug($"TAXPOS RATE accumulated (new group): [{context.TaxPosRate}]")
ElseIf itemData.SpecName = "INVOICE_TAXPOS_AMOUNT" Then
context.TaxPosAmount = FormatCurrency(itemData.Value, context.CurrencySymbol)
itemData.Display = False
_logger.Debug($"TAXPOS AMOUNT accumulated: [{context.TaxPosAmount}]")
ElseIf itemData.SpecName = "INVOICE_TAXPOS_TYPE" Then
' Vollständige Gruppe (RATE + AMOUNT + TYPE) ist da → eine Zeile rendern:
' "BT 119 <TAB> 7.00 % <TAB> 148,14 € <TAB> VAT"
If Not String.IsNullOrEmpty(context.TaxPosRate) Then
' Nur Zeilenvorschub wenn dies NICHT die allererste Zeile der Area ist
' (z.B. wenn die Gruppe direkt mit RATE begann, ohne BASEAMOUNT davor).
If Not context.IsFirstTaxPosDisplay Then
context.YPosition += LINE_HEIGHT
End If
context.IsFirstTaxPosDisplay = False
context.PDF.DrawText(fontResName, COL_TAXPOS_LABEL, context.YPosition, context.TaxPosRateCaption)
context.PDF.DrawText(fontResName, COL_TAXPOS_VALUE1, context.YPosition, $"{context.TaxPosRate} %")
context.PDF.DrawText(fontResName, COL_TAXPOS_AMOUNT, context.YPosition, context.TaxPosAmount)
context.PDF.DrawText(fontResName, COL_TAXPOS_TYPE, context.YPosition, itemData.Value)
_logger.Debug($"TAXPOS row rendered: [{context.TaxPosRateCaption}] [{context.TaxPosRate} %] [{context.TaxPosAmount}] [{itemData.Value}] at Y={context.YPosition}")
' Reset für nächste TAXPOS-Gruppe
context.TaxPosRate = ""
context.TaxPosRateCaption = ""
context.TaxPosAmount = ""
Else
_logger.Debug($"TAXPOS TYPE DUPLICATE/incomplete ignored: [{itemData.Value}]")
End If
itemData.Display = False
ElseIf itemData.Value.Contains("EXEMPTION") Then
_logger.Debug($"We got an Exemption: {itemData.Value}")
End If
End Sub
#End Region
#Region "Rendering"
Private Sub RenderDisplayItem(context As PdfRenderContext, itemData As InvoiceItemData, isAreaSwitch As Boolean)
' Spezielle Behandlung für TAXPOS: Erstes Display-Item nach Area-Switch
Dim skipLineHeight As Boolean = isAreaSwitch OrElse context.IsFirstTaxPosDisplay
If context.IsFirstTaxPosDisplay Then
context.IsFirstTaxPosDisplay = False ' Reset nach Verwendung
_logger.Debug($"RenderDisplayItem: TAXPOS first display item - skipping line height")
End If
' Y-Position anpassen - ABER NICHT nach Area-Switch oder erstem TAXPOS Display!
If Not itemData.IsLastRowSameArea AndAlso Not skipLineHeight Then
context.YPosition += LINE_HEIGHT
_logger.Debug($"RenderDisplayItem: Adding line height. New YPosition: {context.YPosition}")
End If
' Formatierung für Währungsfelder
Dim displayValue As String = itemData.Value
If ShouldFormatAsCurrency(context.CurrentArea, itemData.SpecName) Then
displayValue = FormatCurrency(itemData.Value, context.CurrencySymbol)
End If
' Rendern basierend auf Layout
If Not String.IsNullOrEmpty(itemData.Caption) Then
RenderLabelValuePair(context, itemData.Caption, displayValue)
Else
If itemData.IsLastRowSameArea Then
context.PDF.DrawText(fontResName, itemData.XPosition, context.YPosition, displayValue)
Else
RenderTextWithWrapping(context, displayValue)
End If
End If
End Sub
Private Function ShouldFormatAsCurrency(area As String, specName As String) As Boolean
If area <> "AMOUNT" AndAlso area <> "ALLOWANCE" Then Return False
Dim currencyFields As String() = {
"INVOICE_TOTAL_TAX", "INVOICE_TOTAL_NET", "INVOICE_TOTAL_GROSS",
"INVOICE_TOTAL_CHARGE_AMOUNT", "POSITION_ALLOWANCE_ACTUAL_AMOUNT",
"RECEIPT_ALLOWANCE_ACTUAL_AMOUNT", "POSITION_ALLOWANCE_CALCULATION_PERCENT",
"RECEIPT_ALLOWANCE_CALCULATION_PERCENT"
}
Return currencyFields.Contains(specName)
End Function
Private Sub RenderLabelValuePair(context As PdfRenderContext, label As String, value As String)
context.PDF.DrawText(fontResName, MARGIN_LEFT, context.YPosition, label)
If context.CreateTextBox Then
Dim textBoxYPos As Integer = context.YPosition - 3
context.PDF.DrawTextBox(fontResName, COL_VALUE_X, textBoxYPos, 90, YCoo_TextBoxPlus5(textBoxYPos),
TextAlignment.TextAlignmentFar, TextAlignment.TextAlignmentCenter,
value)
Else
context.PDF.DrawText(fontResName, COL_VALUE_X, context.YPosition, value)
End If
End Sub
Private Sub RenderTextWithWrapping(context As PdfRenderContext, text As String)
If text.Length > MAX_TEXT_LENGTH_FULL Then
For i As Integer = 0 To text.Length - 1 Step MAX_TEXT_LENGTH_FULL
Dim length As Integer = Math.Min(MAX_TEXT_LENGTH_FULL, text.Length - i)
Dim part As String = text.Substring(i, length)
context.PDF.DrawText(fontResName, MARGIN_LEFT, context.YPosition, part)
context.YPosition += LINE_HEIGHT
Next
Else
context.PDF.DrawText(fontResName, MARGIN_LEFT, context.YPosition, text)
End If
End Sub
Private Sub RenderMultiLineText(context As PdfRenderContext, text As String, xPos As Integer, maxLength As Integer,
Optional ByRef firstRenderedY As Integer = -1,
Optional ByRef firstRenderedPage As Integer = -1)
Dim partsNL As List(Of String) = StringFunctions.SplitTextByNewLine(text)
Dim isFirstPart As Boolean = True
For Each linePart As String In partsNL
Dim parts As List(Of String) = StringFunctions.SplitText_Length(linePart, maxLength)
For Each part As String In parts
' ✓ PROBLEM GEFUNDEN: requiredSpace=10 ist zu viel!
' Bei YDynamic=230 prüft es: 230 + 10 = 240 >= 270 → TRUE → Seitenumbruch
' ABER: Die Zeile selbst benötigt nur 5mm! 230 + 5 = 235 wäre OK!
Dim requiredSpace As Integer = 6 ' ← REDUZIERT: Nur 1mm Puffer (Zeile=5mm + 1mm)
If (context.YDynamic + requiredSpace) >= PAGE_HEIGHT_LIMIT Then
' Neue Seite erstellen
CreateNewPage(context)
context.YDynamic = context.YPosition
' WICHTIG: CurrentPositionStartY wird hier NICHT geändert.
' Der korrekte Anker ist die Zeile von INVOICE_POSITION_ARTICLE,
' gesetzt in HandleArticleTextFollowUp VOR dem RenderMultiLineText-Aufruf.
' - CONTINUATION-Break: Artikel auf alter Seite → CurrentPositionStartY
' bleibt auf der alten Seite (Tax/Amount landen korrekt neben Artikel).
' - FIRST-part-Break im ARTICLE-Feld: HandleArticleTextFollowUp hat
' CurrentPositionStartY = context.YDynamic gesetzt, BEVOR CreateNewPage
' aufgerufen wurde → nach dem Seitenbruch zeigt YDynamic auf die neue
' Seite und der Anker ist bereits korrekt (45).
If isFirstPart Then
_logger.Debug($"RenderMultiLineText: Page break at FIRST part! YDynamic={context.YDynamic}, CurrentPositionStartY unchanged={context.CurrentPositionStartY}")
Else
_logger.Debug($"RenderMultiLineText: Page break at continuation! YDynamic={context.YDynamic}, CurrentPositionStartY unchanged={context.CurrentPositionStartY}")
End If
End If
' *** BUGFIX (lange mehrzeilige ARTICLE-Texte über Seitenbruch hinweg) ***
' Erfasst Y/Seite des ALLERERSTEN tatsächlich gezeichneten Textteils - das ist
' immer die korrekte Ankerzeile, unabhängig davon, ob danach noch weitere Zeilen
' folgen oder ein Seitenbruch mitten im Text auftritt. Wird nur befüllt, wenn der
' Aufrufer (HandleArticleTextFollowUp) die Parameter explizit übergibt.
If firstRenderedY = -1 Then
firstRenderedY = context.YDynamic
firstRenderedPage = context.PDF.GetPageCount()
End If
_logger.Debug($"RenderMultiLineText: Rendering part: [{part}] at Y position: {context.YDynamic}")
context.PDF.DrawText(fontResName, xPos, context.YDynamic, part)
context.YDynamic += LINE_HEIGHT
context.YPlus += LINE_HEIGHT
isFirstPart = False
Next
Next
End Sub
#End Region
#Region "Finalisierung"
Private Function FinalizePDF(pdf As GdPicturePDF, paths As FilePaths) As FileInfo
Try
' XML einbetten
If File.Exists(paths.XmlPath) Then
pdf.EmbedFile(paths.XmlPath, "E-invoice XML attachment")
Else
_logger.Info("XML File is not existing and could not be embedded!")
Return Nothing
End If
pdf.EnableCompression(True)
' Speichern
Dim status As GdPictureStatus = pdf.SaveToFile(paths.OutputPath)
If status = GdPictureStatus.OK Then
_logger.Info("PDF VisualReceipt generated successfully!")
_logger.Debug("Vor MOVE... oxmlFilePath: [{0}] / oTempFilePath: [{1}]", paths.XmlPath, paths.TempXmlPath)
File.Move(paths.XmlPath, paths.TempXmlPath)
pdf.CloseDocument()
Dim result As New FileInfo(paths.OutputPath)
_logger.Info("Create_PDFfromXML() End successfully. File [{0}] written.", result.FullName)
Return result
Else
_logger.Warn($"Error generating PDF VisualReceipt: {status}")
pdf.CloseDocument()
Return Nothing
End If
Catch ex As Exception
pdf.CloseDocument()
_logger.Error("Error finalizing PDF", ex)
Return Nothing
End Try
End Function
#End Region
#Region "Helper Classes"
Private Class FilePaths
Public Property XmlPath As String
Public Property TempPath As String
Public Property TempXmlPath As String
Public Property PdfFilename As String
Public Property OutputPath As String
Public Property CreatedString As String
End Class
Private Class PdfRenderContext
Public Property PDF As GdPicturePDF
Public Property YPosition As Integer
Public Property CurrentArea As String
Public Property CurrencySymbol As String
Public Property PositionCount As Integer
Public Property HasDiscount As Boolean
Public Property YDynamic As Integer
Public Property YPlus As Integer
Public Property CreateTextBox As Boolean
Public Property CreatedString As String
Public Property TaxPosText As String ' Legacy, nicht mehr aktiv genutzt - bleibt für Kompatibilität
Public Property TaxPosRate As String
Public Property TaxPosRateCaption As String
Public Property TaxPosAmount As String
Public Property IsFirstTaxPosDisplay As Boolean
Public Property CurrentPositionStartY As Integer
' Seitennummer auf der CurrentPositionStartY gültig ist.
' Wird beim Setzen von CurrentPositionStartY immer mitgesetzt.
Public Property CurrentPositionPage As Integer
Public Sub New(pdf As GdPicturePDF, createdString As String)
Me.PDF = pdf
Me.CreatedString = createdString
YPosition = MARGIN_TOP
CurrentArea = String.Empty
CurrencySymbol = ""
PositionCount = 0
HasDiscount = False
YDynamic = 0
YPlus = 0
CreateTextBox = False
TaxPosText = ""
TaxPosRate = ""
TaxPosRateCaption = ""
TaxPosAmount = ""
IsFirstTaxPosDisplay = False
CurrentPositionStartY = 0
CurrentPositionPage = 1
End Sub
End Class
Private Class InvoiceItemData
Public Property Area As String
Public Property SpecName As String
Public Property Value As String
Public Property Caption As String
Public Property Display As Boolean
Public Property IsLastRowSameArea As Boolean
Public Property XPosition As Integer
Public Property AreaSwitch As Boolean
' Neue Property für normalisierte Area
Public ReadOnly Property NormalizedArea As String
Get
Return NormalizeAreaName(Area)
End Get
End Property
' Neue Property für Area-Caption (nur bei INCL_NOTE#)
Public ReadOnly Property AreaCaptionFromPattern As String
Get
If Area.StartsWith("INCL_NOTE#") Then
Return Area.Substring(10) ' Entfernt "INCL_NOTE#"
End If
Return String.Empty
End Get
End Property
Public Sub New(row As DataRow)
Area = GetSafeString(row, "Area")
SpecName = GetSafeString(row, "SPEC_NAME")
Value = GetSafeString(row, "ITEM_VALUE")
Caption = GetSafeString(row, "Row_Caption")
Display = GetSafeBoolean(row, "Display", False)
IsLastRowSameArea = GetSafeBoolean(row, "Y_eq_lastrow", False)
XPosition = GetSafeInteger(row, "xPosition", MARGIN_LEFT)
AreaSwitch = False
End Sub
Private Shared Function GetSafeString(row As DataRow, columnName As String) As String
If row.Table.Columns.Contains(columnName) AndAlso Not IsDBNull(row.Item(columnName)) Then
Return row.Item(columnName).ToString()
End If
Return String.Empty
End Function
Private Shared Function GetSafeBoolean(row As DataRow, columnName As String, defaultValue As Boolean) As Boolean
If row.Table.Columns.Contains(columnName) AndAlso Not IsDBNull(row.Item(columnName)) Then
Return CBool(row.Item(columnName))
End If
Return defaultValue
End Function
Private Shared Function GetSafeInteger(row As DataRow, columnName As String, defaultValue As Integer) As Integer
If row.Table.Columns.Contains(columnName) AndAlso Not IsDBNull(row.Item(columnName)) Then
Return CInt(row.Item(columnName))
End If
Return defaultValue
End Function
' Statische Hilfsmethode für Area-Normalisierung
Private Shared Function NormalizeAreaName(area As String) As String
If area.StartsWith("INCL_NOTE#") Then
Return "INCL_NOTE"
End If
Return area
End Function
End Class
#End Region
#Region "Hilfsfunktionen"
Private Function FormatCurrency(ByVal pValue As String, pCurrencySymbol As String) As String
pValue = pValue.Trim()
Dim oBetrag As Decimal
Dim culture As Globalization.CultureInfo
' Erkennung des Dezimaltrennzeichens
If pValue.Contains(",") AndAlso Not pValue.Contains(".") Then
culture = New Globalization.CultureInfo("de-DE") ' Komma → deutsches Format
ElseIf pValue.Contains(".") AndAlso Not pValue.Contains(",") Then
culture = New Globalization.CultureInfo("en-US") ' Punkt → englisches Format
Else
' Mischformat oder Tausendertrennzeichen → Fallback auf aktuelle Culture
culture = Globalization.CultureInfo.CurrentCulture
End If
' Parsen mit gewählter Culture
If Not Decimal.TryParse(pValue, Globalization.NumberStyles.Any, culture, oBetrag) Then
Throw New FormatException($"Ungültiger Zahlenwert: {pValue}")
End If
' Formatieren mit deutscher Culture
Dim oFormatiert As String = oBetrag.ToString("N2", New Globalization.CultureInfo("de-DE"))
Return $"{oFormatiert} {pCurrencySymbol}"
End Function
Private Function FormatStringT(ByVal text As String) As String
Return text.Replace(vbCr, " - ").Replace(vbLf, "").Replace(vbTab, " ")
End Function
Private Function RemoveNewlinesAndTabs(ByVal text As String) As String
Return text.Replace(vbCr, " - ").Replace(vbLf, "").Replace(vbTab, " ")
End Function
Private Function YCoo_TextBoxMinus5(yPosition As Integer) As Integer
Return yPosition - 5
End Function
Private Function YCoo_TextBoxPlus5(yPosition As Integer) As Integer
Return yPosition + 5
End Function
Function SplitTextByNewLine(text As String) As List(Of String)
If String.IsNullOrEmpty(text) Then
Return New List(Of String)()
End If
' Zerlege den Text anhand von Zeilenumbrüchen
Dim lines As List(Of String) = text.Split({vbCrLf, vbLf, vbCr}, StringSplitOptions.None).ToList()
Return lines
End Function
Private Function Return_InvType(pType As String) As String
Dim oReturn As String = "Rechnung/invoice"
If pType = "380" Then
oReturn = "Handelsrechnung/Commercial invoice"
ElseIf pType = "381" Then
oReturn = "Gutschriftanzeige/Credit advice"
ElseIf pType = "384" Then
oReturn = "Rechnungskorrektur/Invoice correction"
ElseIf pType = "386" Then
oReturn = "Vorauszahlungsrechnung/Prepayment invoice"
ElseIf pType = "326" Then
oReturn = "Teilrechnung/Partial invoice"
ElseIf pType = "84" Then
oReturn = "Gutschrift/Credit note"
ElseIf pType = "389" Then
oReturn = "Gutschriftsverfahren/Credit note procedure"
End If
Return oReturn
End Function
Private Function Return_UnitType(pType As String) As String
Dim oReturn As String = "Stück/pc"
If pType = "C62" Then
oReturn = "Stück/pc"
ElseIf pType = "DAY" Then
oReturn = "Tag/day"
ElseIf pType = "HAR" Then
oReturn = "Hek/hec"
ElseIf pType = "HUR" Then
oReturn = "h"
ElseIf pType = "KGM" Then
oReturn = "kg"
ElseIf pType = "KTM" Then
oReturn = "km"
ElseIf pType = "KWH" Then
oReturn = pType
ElseIf pType = "LS" Then
oReturn = "pausch/flat"
ElseIf pType = "MIN" Then
oReturn = "minute"
ElseIf pType = "MTK" Then
oReturn = "QM/SM"
ElseIf pType = "Kubik/CM" Then
oReturn = "MTR"
ElseIf pType = "Meter" Then
oReturn = "minute"
ElseIf pType = "P1" Then
oReturn = "%"
ElseIf pType = "SET" Then
oReturn = "Set"
ElseIf pType = "TNE" Then
oReturn = "Tonne/ton"
ElseIf pType = "WEE" Then
oReturn = "Woche/week"
End If
Return oReturn
End Function
#End Region
End Class