Refactor: Rename DocumentOperator to DocumentService
Updated project and solution files to rename and restructure `DocumentOperator` to `DocumentService`. Modified project references in `DocumentService.API.csproj`, `DocumentService.Application.csproj`, and `DocumentService.Infrastructure.csproj` to reflect the new naming convention. Updated `DocumentService.sln` to remove old project references, add new ones, and adjust configuration and nested project mappings accordingly. This change aligns the project structure with the new naming convention.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using DocumentService.Domain.Models.ValueObjects;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.AddAnnotation;
|
||||
|
||||
/// <summary>
|
||||
/// Command to add an annotation to a PDF document.
|
||||
/// </summary>
|
||||
public record AddAnnotationCommand : IRequest<byte[]>
|
||||
{
|
||||
public required Stream PdfStream { get; init; }
|
||||
public required AnnotationType AnnotationType { get; init; }
|
||||
public required int PageNumber { get; init; }
|
||||
public required (double X1, double Y1, double X2, double Y2) Rectangle { get; init; }
|
||||
public string? Content { get; init; }
|
||||
public string? Author { get; init; }
|
||||
public string? Color { get; init; }
|
||||
public TextMarkupStyle? TextMarkupStyle { get; init; }
|
||||
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for AddAnnotationCommand.
|
||||
/// </summary>
|
||||
public class AddAnnotationHandler(IPdfProcessor pdfProcessor) : IRequestHandler<AddAnnotationCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(AddAnnotationCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.AddAnnotationAsync(
|
||||
request.PdfStream,
|
||||
request.AnnotationType,
|
||||
request.PageNumber,
|
||||
request.Rectangle,
|
||||
request.Content,
|
||||
request.Author,
|
||||
request.Color,
|
||||
request.TextMarkupStyle,
|
||||
request.Origin);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for AddAnnotationCommand.
|
||||
/// </summary>
|
||||
public class AddAnnotationValidator : AbstractValidator<AddAnnotationCommand>
|
||||
{
|
||||
public AddAnnotationValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
|
||||
RuleFor(x => x.PageNumber)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("Page number must be greater than 0");
|
||||
|
||||
RuleFor(x => x.Content)
|
||||
.NotEmpty()
|
||||
.When(x => x.AnnotationType == AnnotationType.FreeText || x.AnnotationType == AnnotationType.StickyNote)
|
||||
.WithMessage("Content is required for FreeText and StickyNote annotations");
|
||||
|
||||
RuleFor(x => x.TextMarkupStyle)
|
||||
.NotNull()
|
||||
.When(x => x.AnnotationType == AnnotationType.TextMarkup)
|
||||
.WithMessage("TextMarkupStyle is required for TextMarkup annotations");
|
||||
|
||||
RuleFor(x => x.Color)
|
||||
.Matches("^[0-9A-Fa-f]{6}$")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.Color))
|
||||
.WithMessage("Color must be a 6-digit hex value (e.g., 'FF0000' for red)");
|
||||
|
||||
RuleFor(x => x.Rectangle)
|
||||
.Must(r => r.X2 > r.X1 && r.Y2 > r.Y1)
|
||||
.WithMessage("Rectangle coordinates must define a valid area (X2 > X1 and Y2 > Y1)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.AddAttachments;
|
||||
|
||||
/// <summary>
|
||||
/// Command to add one or more attachments to a PDF document (supports PDF/A-3)
|
||||
/// </summary>
|
||||
public record AddAttachmentsCommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document stream. Must be positioned at the beginning (Position = 0).
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of attachments to embed (filename, content, optional MIME type)
|
||||
/// </summary>
|
||||
public required IReadOnlyList<AttachmentFile> Attachments { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a file to be attached to a PDF
|
||||
/// </summary>
|
||||
public record AttachmentFile
|
||||
{
|
||||
/// <summary>
|
||||
/// File name (e.g., "invoice.xml", "document.pdf")
|
||||
/// </summary>
|
||||
public required string FileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File content as byte array
|
||||
/// </summary>
|
||||
public required byte[] Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME type (optional, e.g., "application/xml", "application/pdf")
|
||||
/// If not provided, will be inferred from file extension
|
||||
/// </summary>
|
||||
public string? MimeType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for AddAttachmentsCommand
|
||||
/// </summary>
|
||||
public class AddAttachmentsCommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<AddAttachmentsCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(AddAttachmentsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert to tuple list for IPdfProcessor
|
||||
var attachmentTuples = request.Attachments
|
||||
.Select(a => (a.FileName, a.Content, a.MimeType))
|
||||
.ToList();
|
||||
|
||||
return await pdfProcessor.AddAttachmentsAsync(request.PdfStream, attachmentTuples);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for AddAttachmentsCommand
|
||||
/// </summary>
|
||||
public class AddAttachmentsCommandValidator : AbstractValidator<AddAttachmentsCommand>
|
||||
{
|
||||
public AddAttachmentsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
|
||||
RuleFor(x => x.Attachments)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.WithMessage("At least one attachment is required");
|
||||
|
||||
RuleForEach(x => x.Attachments).ChildRules(attachment =>
|
||||
{
|
||||
attachment.RuleFor(a => a.FileName)
|
||||
.NotEmpty()
|
||||
.WithMessage("Attachment file name is required");
|
||||
|
||||
attachment.RuleFor(a => a.Content)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.WithMessage("Attachment content is required");
|
||||
});
|
||||
}
|
||||
}
|
||||
125
DocumentService.Application/AddStamp/AddStampCommand.cs
Normal file
125
DocumentService.Application/AddStamp/AddStampCommand.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.AddStamp;
|
||||
|
||||
/// <summary>
|
||||
/// Command to add a stamp (text, image, or predefined) to PDF pages.
|
||||
/// Combines Command, Handler, and Validator in a single file (Vertical Slice pattern).
|
||||
/// </summary>
|
||||
public record AddStampCommand : IRequest<byte[]>
|
||||
{
|
||||
public required Stream PdfStream { get; init; }
|
||||
public required Domain.Models.ValueObjects.StampType StampType { get; init; }
|
||||
public int[]? PageNumbers { get; init; } // null = all pages
|
||||
public required (double X, double Y) Position { get; init; }
|
||||
public (double Width, double Height)? Size { get; init; }
|
||||
public Domain.Models.ValueObjects.AnnotationOrigin Origin { get; init; } = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft;
|
||||
public string? Text { get; init; }
|
||||
public string? FontName { get; init; }
|
||||
public double? FontSize { get; init; }
|
||||
public string? Color { get; init; }
|
||||
public double? Opacity { get; init; }
|
||||
public double? Rotation { get; init; }
|
||||
public Domain.Models.ValueObjects.StampPlacement Placement { get; init; } = Domain.Models.ValueObjects.StampPlacement.Foreground;
|
||||
public byte[]? ImageBytes { get; init; }
|
||||
public Domain.Models.ValueObjects.PredefinedStampType? PredefinedType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for AddStampCommand.
|
||||
/// </summary>
|
||||
public class AddStampHandler(Common.Interfaces.IPdfProcessor pdfProcessor) : IRequestHandler<AddStampCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(AddStampCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.AddStampAsync(
|
||||
command.PdfStream,
|
||||
command.StampType,
|
||||
command.PageNumbers,
|
||||
command.Position,
|
||||
command.Size,
|
||||
command.Origin,
|
||||
command.Text,
|
||||
command.FontName,
|
||||
command.FontSize,
|
||||
command.Color,
|
||||
command.Opacity,
|
||||
command.Rotation,
|
||||
command.Placement,
|
||||
command.ImageBytes,
|
||||
command.PredefinedType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for AddStampCommand.
|
||||
/// </summary>
|
||||
public class AddStampValidator : AbstractValidator<AddStampCommand>
|
||||
{
|
||||
public AddStampValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull().WithMessage("PDF stream is required");
|
||||
|
||||
RuleFor(x => x.Position.X)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("Position X must be >= 0");
|
||||
|
||||
RuleFor(x => x.Position.Y)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("Position Y must be >= 0");
|
||||
|
||||
// Text stamp validation
|
||||
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Text, () =>
|
||||
{
|
||||
RuleFor(x => x.Text)
|
||||
.NotEmpty().WithMessage("Text is required for Text stamp type");
|
||||
});
|
||||
|
||||
// Image stamp validation
|
||||
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Image, () =>
|
||||
{
|
||||
RuleFor(x => x.ImageBytes)
|
||||
.NotNull().WithMessage("Image bytes are required for Image stamp type")
|
||||
.Must(bytes => bytes != null && bytes.Length > 0).WithMessage("Image bytes cannot be empty");
|
||||
});
|
||||
|
||||
// Predefined stamp validation
|
||||
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Predefined, () =>
|
||||
{
|
||||
RuleFor(x => x.PredefinedType)
|
||||
.NotNull().WithMessage("Predefined type is required for Predefined stamp type");
|
||||
});
|
||||
|
||||
// Optional parameter validations
|
||||
When(x => x.Opacity.HasValue, () =>
|
||||
{
|
||||
RuleFor(x => x.Opacity!.Value)
|
||||
.InclusiveBetween(0.0, 1.0).WithMessage("Opacity must be between 0.0 and 1.0");
|
||||
});
|
||||
|
||||
When(x => x.Rotation.HasValue, () =>
|
||||
{
|
||||
RuleFor(x => x.Rotation!.Value)
|
||||
.InclusiveBetween(0.0, 360.0).WithMessage("Rotation must be between 0 and 360 degrees");
|
||||
});
|
||||
|
||||
When(x => x.FontSize.HasValue, () =>
|
||||
{
|
||||
RuleFor(x => x.FontSize!.Value)
|
||||
.GreaterThan(0).WithMessage("Font size must be positive");
|
||||
});
|
||||
|
||||
When(x => !string.IsNullOrWhiteSpace(x.Color), () =>
|
||||
{
|
||||
RuleFor(x => x.Color!)
|
||||
.Matches(@"^[0-9A-Fa-f]{6}$").WithMessage("Color must be 6-digit hex (e.g., 'FF0000')");
|
||||
});
|
||||
|
||||
When(x => x.PageNumbers != null, () =>
|
||||
{
|
||||
RuleFor(x => x.PageNumbers!)
|
||||
.Must(pages => pages.All(p => p > 0)).WithMessage("All page numbers must be >= 1");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using AutoMapper;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.CheckPdfAttachments.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for checking PDF attachments (Stream-based)
|
||||
/// </summary>
|
||||
public record CheckPdfAttachmentsQuery : IRequest<AttachmentCheckResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for CheckPdfAttachmentsQuery
|
||||
/// Orchestrates PDF attachment checking using IPdfProcessor and AutoMapper
|
||||
/// </summary>
|
||||
public class CheckPdfAttachmentsQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper)
|
||||
: IRequestHandler<CheckPdfAttachmentsQuery, AttachmentCheckResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks PDF attachments and returns detailed metadata
|
||||
/// </summary>
|
||||
public async Task<AttachmentCheckResult> Handle(CheckPdfAttachmentsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Call DevExpress service directly with stream (exceptions propagate naturally)
|
||||
var attachmentInfo = await PdfProcessor.CheckAttachmentsAsync(request.PdfStream);
|
||||
|
||||
// Map DTO to response DTO using AutoMapper
|
||||
return Mapper.Map<AttachmentCheckResult>(attachmentInfo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace DocumentService.Application.CheckPdfAttachments.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for CheckPdfAttachmentsQuery
|
||||
/// Ensures PdfStream is not null
|
||||
/// </summary>
|
||||
public class CheckPdfAttachmentsQueryValidator : AbstractValidator<CheckPdfAttachmentsQuery>
|
||||
{
|
||||
public CheckPdfAttachmentsQueryValidator()
|
||||
{
|
||||
// Rule: PdfStream must be provided and non-empty
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PdfStream is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace DocumentService.Application.Common.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR Pipeline Behavior that logs requests and tracks performance
|
||||
/// Executes AFTER ValidationBehavior, BEFORE Handler
|
||||
/// </summary>
|
||||
public class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> Logger) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
|
||||
// Performance Tracking
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Handler ausführen
|
||||
var response = await next(cancellationToken);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
// Request Success
|
||||
Logger.LogInformation(
|
||||
"Handled {RequestName} in {ElapsedMs}ms",
|
||||
requestName,
|
||||
stopwatch.ElapsedMilliseconds
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
// Request Failed
|
||||
Logger.LogError(
|
||||
ex,
|
||||
"Error handling {RequestName} after {ElapsedMs}ms: {ErrorMessage}",
|
||||
requestName,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
ex.Message
|
||||
);
|
||||
|
||||
throw; // Exception weiterwerfen (wird von Exception Middleware gefangen)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.Common.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR Pipeline Behavior that validates requests using FluentValidation
|
||||
/// Executes BEFORE the Handler
|
||||
/// </summary>
|
||||
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
||||
|
||||
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
|
||||
{
|
||||
_validators = validators;
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Wenn keine Validators registriert sind, direkt weiter zum Handler
|
||||
if (!_validators.Any())
|
||||
{
|
||||
return await next();
|
||||
}
|
||||
|
||||
// Validation Context erstellen
|
||||
var context = new ValidationContext<TRequest>(request);
|
||||
|
||||
// Alle Validators parallel ausführen
|
||||
var validationResults = await Task.WhenAll(
|
||||
_validators.Select(v => v.ValidateAsync(context, cancellationToken))
|
||||
);
|
||||
|
||||
// Fehler sammeln
|
||||
var failures = validationResults
|
||||
.Where(r => !r.IsValid)
|
||||
.SelectMany(r => r.Errors)
|
||||
.ToList();
|
||||
|
||||
// Bei Fehlern: ValidationException werfen (wird von Exception Middleware gefangen)
|
||||
if (failures.Any())
|
||||
{
|
||||
throw new ValidationException(failures);
|
||||
}
|
||||
|
||||
// Validation erfolgreich ? weiter zum Handler
|
||||
return await next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace DocumentService.Application.Common.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration settings for ZUGFeRD/Factur-X/XRechnung detection
|
||||
/// </summary>
|
||||
public class ZugferdSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Exact ZUGFeRD/Factur-X/XRechnung file names to check (case-insensitive)
|
||||
/// </summary>
|
||||
public List<string> ZugferdFileNames { get; set; } = new()
|
||||
{
|
||||
"factur-x.xml",
|
||||
"zugferd-invoice.xml",
|
||||
"ZUGFeRD-invoice.xml",
|
||||
"xrechnung.xml"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Partial filename patterns for ZUGFeRD detection (case-insensitive)
|
||||
/// </summary>
|
||||
public List<string> ZugferdFileNamePatterns { get; set; } = new()
|
||||
{
|
||||
"factur",
|
||||
"zugferd",
|
||||
"xrechnung",
|
||||
"peppol"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for attachment check result returned to API layer
|
||||
/// </summary>
|
||||
public record AttachmentCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether the PDF contains any attachments
|
||||
/// </summary>
|
||||
public bool HasAttachments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of attachments in the PDF
|
||||
/// </summary>
|
||||
public int AttachmentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of attachment metadata (file details)
|
||||
/// </summary>
|
||||
public List<AttachmentDto> Attachments { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for individual attachment metadata
|
||||
/// </summary>
|
||||
public record AttachmentDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Attachment file name (e.g., "invoice.xml", "document.pdf")
|
||||
/// </summary>
|
||||
public string FileName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// MIME type of the attachment (e.g., "text/xml", "application/pdf")
|
||||
/// </summary>
|
||||
public string MimeType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Attachment file size in bytes
|
||||
/// </summary>
|
||||
public long Size { get; init; }
|
||||
}
|
||||
60
DocumentService.Application/Common/DTOs/AttachmentInfo.cs
Normal file
60
DocumentService.Application/Common/DTOs/AttachmentInfo.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Represents complete attachment information for a PDF document.
|
||||
/// Immutable value object containing attachment presence flag, count, and detailed metadata.
|
||||
/// </summary>
|
||||
public sealed class AttachmentInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the PDF contains any attachments
|
||||
/// </summary>
|
||||
public bool HasAttachments { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of attachments in the PDF
|
||||
/// </summary>
|
||||
public int AttachmentCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of attachment metadata (file details)
|
||||
/// </summary>
|
||||
public IReadOnlyList<AttachmentMetadata> Attachments { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the AttachmentInfo class.
|
||||
/// </summary>
|
||||
/// <param name="hasAttachments">Whether PDF has attachments</param>
|
||||
/// <param name="attachmentCount">Total number of attachments</param>
|
||||
/// <param name="attachments">List of attachment metadata (can be empty)</param>
|
||||
public AttachmentInfo(bool hasAttachments, int attachmentCount, IReadOnlyList<AttachmentMetadata> attachments)
|
||||
{
|
||||
HasAttachments = hasAttachments;
|
||||
AttachmentCount = attachmentCount;
|
||||
Attachments = attachments ?? [];
|
||||
|
||||
// Defensive Programming: Ensure count matches list length
|
||||
if (Attachments.Count != attachmentCount)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Attachment count mismatch: expected {attachmentCount}, got {Attachments.Count}",
|
||||
nameof(attachments));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an AttachmentInfo instance for a PDF with no attachments.
|
||||
/// </summary>
|
||||
public static AttachmentInfo Empty =>
|
||||
new(
|
||||
hasAttachments: false,
|
||||
attachmentCount: 0,
|
||||
attachments: []);
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return HasAttachments
|
||||
? $"PDF has {AttachmentCount} attachment(s): {string.Join(", ", Attachments.Select(a => a.FileName))}"
|
||||
: "PDF has no attachments";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Represents metadata of a single PDF attachment (embedded file).
|
||||
/// Immutable value object containing file information without the actual binary data.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the AttachmentMetadata class.
|
||||
/// </remarks>
|
||||
/// <param name="fileName">Attachment file name</param>
|
||||
/// <param name="mimeType">MIME type (e.g., "text/xml")</param>
|
||||
/// <param name="sizeBytes">File size in bytes</param>
|
||||
public sealed class AttachmentMetadata(string fileName, string mimeType, long sizeBytes)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the attachment file name (e.g., "invoice.xml", "document.pdf")
|
||||
/// </summary>
|
||||
public string FileName { get; } = fileName ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the MIME type of the attachment (e.g., "text/xml", "application/pdf")
|
||||
/// </summary>
|
||||
public string MimeType { get; } = mimeType ?? "application/octet-stream"; // Default MIME type if unknown
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attachment file size in bytes
|
||||
/// </summary>
|
||||
public long SizeBytes { get; } = sizeBytes;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attachment file size in kilobytes (computed property)
|
||||
/// </summary>
|
||||
public double SizeKB => SizeBytes / 1024.0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attachment file size in megabytes (computed property)
|
||||
/// </summary>
|
||||
public double SizeMB => SizeBytes / 1024.0 / 1024.0;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{FileName} ({MimeType}, {SizeKB:F2} KB)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// PDF/A validation result including conformance level and validation errors/warnings
|
||||
/// </summary>
|
||||
public record PdfAValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the PDF is valid (no errors)
|
||||
/// </summary>
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PDF version (e.g., "1.4", "1.7")
|
||||
/// </summary>
|
||||
public string PdfVersion { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Number of pages in the PDF
|
||||
/// </summary>
|
||||
public int PageCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File size in bytes
|
||||
/// </summary>
|
||||
public long FileSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the PDF is encrypted
|
||||
/// </summary>
|
||||
public bool Encrypted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PDF/A version (e.g., "PDF/A-1b", "PDF/A-2a", "PDF/A-3u") or null if not PDF/A compliant
|
||||
/// </summary>
|
||||
public string? PdfAVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the PDF conforms to PDF/A standard
|
||||
/// </summary>
|
||||
public bool PdfACompliant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors (e.g., "PDF/A documents cannot be encrypted")
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Validation warnings (e.g., "Manual verification recommended: All fonts must be embedded")
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Response mit PDF-Metadaten
|
||||
/// </summary>
|
||||
/// <param name="PageCount">Anzahl der Seiten</param>
|
||||
/// <param name="FileSizeBytes">Dateigröße in Bytes</param>
|
||||
/// <param name="FileSizeMB">Dateigröße in MB (gerundet auf 2 Dezimalstellen)</param>
|
||||
/// <param name="PdfVersion">PDF-Version (z.B. "1.4")</param>
|
||||
/// <param name="HasAttachments">Hat das PDF Anhänge?</param>
|
||||
/// <param name="AttachmentCount">Anzahl der Anhänge</param>
|
||||
/// <param name="IsEncrypted">Ist das PDF passwortgeschützt/verschlüsselt?</param>
|
||||
public record PdfValidationResult(
|
||||
int PageCount,
|
||||
long FileSizeBytes,
|
||||
double FileSizeMB,
|
||||
string PdfVersion,
|
||||
bool HasAttachments,
|
||||
int AttachmentCount,
|
||||
bool IsEncrypted);
|
||||
93
DocumentService.Application/Common/DTOs/SwissQrBillDto.cs
Normal file
93
DocumentService.Application/Common/DTOs/SwissQrBillDto.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Swiss QR Bill data transfer object (mapped from Codecrete Bill)
|
||||
/// </summary>
|
||||
public record SwissQrBillDto
|
||||
{
|
||||
/// <summary>QR Bill standard version (e.g., "V2_0")</summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>Payment amount (null if not specified)</summary>
|
||||
public decimal? Amount { get; init; }
|
||||
|
||||
/// <summary>Payment currency (CHF or EUR)</summary>
|
||||
public required string Currency { get; init; }
|
||||
|
||||
/// <summary>Creditor's IBAN account number</summary>
|
||||
public required string Account { get; init; }
|
||||
|
||||
/// <summary>Creditor address</summary>
|
||||
public required AddressDto Creditor { get; init; }
|
||||
|
||||
/// <summary>Payment reference type: "QRR", "SCOR", or "NON"</summary>
|
||||
public required string ReferenceType { get; init; }
|
||||
|
||||
/// <summary>Payment reference (format depends on ReferenceType)</summary>
|
||||
public string? Reference { get; init; }
|
||||
|
||||
/// <summary>Debtor address (optional)</summary>
|
||||
public AddressDto? Debtor { get; init; }
|
||||
|
||||
/// <summary>Additional unstructured message (max 140 chars)</summary>
|
||||
public string? UnstructuredMessage { get; init; }
|
||||
|
||||
/// <summary>Additional structured bill information</summary>
|
||||
public string? BillInformation { get; init; }
|
||||
|
||||
/// <summary>Alternative payment schemes (max 2)</summary>
|
||||
public IReadOnlyList<AlternativeSchemeDto>? AlternativeSchemes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Address data transfer object (mapped from Codecrete Address)
|
||||
/// </summary>
|
||||
public record AddressDto
|
||||
{
|
||||
/// <summary>Address type: "Structured" or "CombinedElements"</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Name of person or company</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Street name (structured address only)</summary>
|
||||
public string? Street { get; init; }
|
||||
|
||||
/// <summary>House number (structured address only)</summary>
|
||||
public string? HouseNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Address line 1 (combined address only).
|
||||
/// OBSOLETE: Use structured address instead. Will be removed when Codecrete v4 is adopted.
|
||||
/// </summary>
|
||||
[Obsolete("Use structured address (Street + HouseNo) instead. This field will be removed when upgrading to Codecrete.SwissQRBill.Generator v4.x")]
|
||||
public string? AddressLine1 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Address line 2 (combined address only).
|
||||
/// OBSOLETE: Use structured address instead. Will be removed when Codecrete v4 is adopted.
|
||||
/// </summary>
|
||||
[Obsolete("Use structured address (Street + HouseNo) instead. This field will be removed when upgrading to Codecrete.SwissQRBill.Generator v4.x")]
|
||||
public string? AddressLine2 { get; init; }
|
||||
|
||||
/// <summary>Postal code</summary>
|
||||
public required string PostalCode { get; init; }
|
||||
|
||||
/// <summary>Town/city name</summary>
|
||||
public required string Town { get; init; }
|
||||
|
||||
/// <summary>Two-letter country code (ISO 3166-1 alpha-2)</summary>
|
||||
public required string CountryCode { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alternative payment scheme (mapped from Codecrete AlternativeScheme)
|
||||
/// </summary>
|
||||
public record AlternativeSchemeDto
|
||||
{
|
||||
/// <summary>Scheme name (e.g., "AV1", "AV2")</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Scheme instruction/parameter</summary>
|
||||
public required string Instruction { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Response containing extracted Swiss QR Code in dual format.
|
||||
/// </summary>
|
||||
/// <param name="Bill">Parsed Swiss QR Bill (structured format)</param>
|
||||
/// <param name="RawLines">Raw Swiss QR Code lines (original newline-separated format)</param>
|
||||
/// <example>
|
||||
/// {
|
||||
/// "bill": {
|
||||
/// "version": "V2_0",
|
||||
/// "amount": 630.20,
|
||||
/// "currency": "CHF",
|
||||
/// "account": "CH953000520280564701R",
|
||||
/// "creditor": {
|
||||
/// "type": "Structured",
|
||||
/// "name": "ALMAT AG",
|
||||
/// "town": "Tagelswangen"
|
||||
/// },
|
||||
/// "referenceType": "QRR",
|
||||
/// "reference": "000000000000000000252080824"
|
||||
/// },
|
||||
/// "rawLines": [
|
||||
/// "SPC",
|
||||
/// "0200",
|
||||
/// "1",
|
||||
/// "CH953000520280564701R",
|
||||
/// "S",
|
||||
/// "ALMAT AG",
|
||||
/// "..."
|
||||
/// ]
|
||||
/// }
|
||||
/// </example>
|
||||
public record SwissQrCodeExtractionResult(
|
||||
SwissQrBillDto Bill,
|
||||
IReadOnlyList<string> RawLines
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace DocumentService.Application.Common.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for ZUGFeRD check result
|
||||
/// </summary>
|
||||
public record ZugferdCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether the PDF contains ZUGFeRD XML attachment
|
||||
/// </summary>
|
||||
public bool HasZugferd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ZUGFeRD XML file name (e.g., "factur-x.xml")
|
||||
/// </summary>
|
||||
public string? ZugferdFileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ZUGFeRD XML file size in bytes
|
||||
/// </summary>
|
||||
public long? ZugferdFileSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME type of the ZUGFeRD XML file
|
||||
/// </summary>
|
||||
public string? ZugferdMimeType { get; init; }
|
||||
}
|
||||
191
DocumentService.Application/Common/Interfaces/IPdfProcessor.cs
Normal file
191
DocumentService.Application/Common/Interfaces/IPdfProcessor.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
|
||||
namespace DocumentService.Application.Common.Interfaces;
|
||||
|
||||
public interface IPdfProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates a PDF and extracts metadata.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>PDF metadata (page count, size, version, attachments)</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty, invalid, or not positioned at the beginning
|
||||
/// </exception>
|
||||
Task<PdfValidationResult> ValidateAsync(Stream pdfStream);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a PDF/A document and checks conformance level.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>PDF/A metadata including conformance level and validation errors/warnings</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty, invalid, or not positioned at the beginning
|
||||
/// </exception>
|
||||
Task<PdfAValidationResult> ValidatePdfAAsync(Stream pdfStream);
|
||||
|
||||
/// <summary>
|
||||
/// Checks for embedded files (attachments) in a PDF document and returns detailed metadata.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>Attachment information (count, file names, MIME types, sizes)</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty, invalid, or not positioned at the beginning
|
||||
/// </exception>
|
||||
Task<AttachmentInfo> CheckAttachmentsAsync(Stream pdfStream);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all embedded files from a PDF document and returns them as a ZIP archive.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>ZIP archive containing all extracted attachments as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty, invalid, or not positioned at the beginning
|
||||
/// </exception>
|
||||
/// <exception cref="Domain.Common.Exceptions.NotFoundException">
|
||||
/// Thrown when PDF contains no attachments
|
||||
/// </exception>
|
||||
Task<byte[]> ExtractAttachmentsAsync(Stream pdfStream);
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple PDF documents into a single PDF.
|
||||
/// </summary>
|
||||
/// <param name="pdfStreams">
|
||||
/// PDF streams to merge (minimum 2 required). Each stream must be readable and positioned
|
||||
/// at the beginning (Position = 0). Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="pageRanges">
|
||||
/// Optional page ranges per PDF (null = all pages). Format: "1-3,5" means pages 1, 2, 3, and 5.
|
||||
/// If null or empty for a PDF, all pages are included. Array length must match pdfStreams length if provided.
|
||||
/// </param>
|
||||
/// <returns>Merged PDF as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when fewer than 2 PDFs provided, any stream is empty/invalid/not at Position=0,
|
||||
/// or page range format is invalid
|
||||
/// </exception>
|
||||
Task<byte[]> MergePdfsAsync(IReadOnlyList<Stream> pdfStreams, IReadOnlyList<string?>? pageRanges = null);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an annotation to a PDF document at the specified page and rectangle.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="annotationType">Type of annotation to add (TextMarkup, FreeText, StickyNote, Circle, Square)</param>
|
||||
/// <param name="pageNumber">Page number (1-based) where annotation should be added</param>
|
||||
/// <param name="rectangle">Annotation bounding rectangle (X1, Y1, X2, Y2)</param>
|
||||
/// <param name="content">Annotation content/comment text (required for FreeText and StickyNote)</param>
|
||||
/// <param name="author">Optional author name</param>
|
||||
/// <param name="color">Optional annotation color in RGB format (hex string like "FF0000" for red)</param>
|
||||
/// <param name="textMarkupStyle">Text markup style (Highlight, Underline, Strikeout) - only for TextMarkup type</param>
|
||||
/// <param name="origin">Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft</param>
|
||||
/// <returns>Annotated PDF as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty/invalid/not at Position=0, page number out of range,
|
||||
/// rectangle invalid, or content missing for types that require it
|
||||
/// </exception>
|
||||
Task<byte[]> AddAnnotationAsync(
|
||||
Stream pdfStream,
|
||||
Domain.Models.ValueObjects.AnnotationType annotationType,
|
||||
int pageNumber,
|
||||
(double X1, double Y1, double X2, double Y2) rectangle,
|
||||
string? content = null,
|
||||
string? author = null,
|
||||
string? color = null,
|
||||
Domain.Models.ValueObjects.TextMarkupStyle? textMarkupStyle = null,
|
||||
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a stamp (text, image, or predefined) to specified pages of a PDF document.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">Input PDF stream (must support reading and seeking)</param>
|
||||
/// <param name="stampType">Type of stamp (Text, Image, or Predefined)</param>
|
||||
/// <param name="pageNumbers">Target page numbers (1-based). Null = all pages.</param>
|
||||
/// <param name="position">Stamp position (X, Y coordinates)</param>
|
||||
/// <param name="size">Stamp size (Width, Height). Null = auto-size for images.</param>
|
||||
/// <param name="origin">Coordinate origin (BottomLeft or TopLeft)</param>
|
||||
/// <param name="text">Text content (required for Text stamps)</param>
|
||||
/// <param name="fontName">Font name (default: Arial)</param>
|
||||
/// <param name="fontSize">Font size in points (default: 12)</param>
|
||||
/// <param name="color">Hex color without # (e.g., "FF0000" for red, default: "000000")</param>
|
||||
/// <param name="opacity">Opacity 0.0 (transparent) to 1.0 (opaque, default: 0.5)</param>
|
||||
/// <param name="rotation">Rotation angle in degrees 0-360 (default: 0)</param>
|
||||
/// <param name="placement">Foreground (on top) or Background (behind content)</param>
|
||||
/// <param name="imageBytes">Image data (required for Image stamps, PNG/JPEG)</param>
|
||||
/// <param name="predefinedType">Predefined stamp type (required for Predefined stamps)</param>
|
||||
/// <returns>Stamped PDF as byte array</returns>
|
||||
/// <exception cref="BadRequestException">Invalid parameters (missing text/image, invalid page numbers, invalid opacity/rotation)</exception>
|
||||
/// <exception cref="PdfProcessingException">DevExpress processing error</exception>
|
||||
Task<byte[]> AddStampAsync(
|
||||
Stream pdfStream,
|
||||
Domain.Models.ValueObjects.StampType stampType,
|
||||
int[]? pageNumbers,
|
||||
(double X, double Y) position,
|
||||
(double Width, double Height)? size = null,
|
||||
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft,
|
||||
string? text = null,
|
||||
string? fontName = null,
|
||||
double? fontSize = null,
|
||||
string? color = null,
|
||||
double? opacity = null,
|
||||
double? rotation = null,
|
||||
Domain.Models.ValueObjects.StampPlacement placement = Domain.Models.ValueObjects.StampPlacement.Foreground,
|
||||
byte[]? imageBytes = null,
|
||||
Domain.Models.ValueObjects.PredefinedStampType? predefinedType = null);
|
||||
|
||||
/// <summary>
|
||||
/// Embeds one or more files as attachments in a PDF document (supports PDF/A-3).
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="attachments">List of files to embed (filename, content, optional MIME type)</param>
|
||||
/// <returns>PDF with embedded attachments as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty/invalid, or attachments list is empty
|
||||
/// </exception>
|
||||
Task<byte[]> AddAttachmentsAsync(
|
||||
Stream pdfStream,
|
||||
IReadOnlyList<(string FileName, byte[] Content, string? MimeType)> attachments);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a standard PDF to PDF/A format.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="pdfALevel">Target PDF/A level (e.g., "PDF/A-1b", "PDF/A-2b", "PDF/A-3b")</param>
|
||||
/// <returns>PDF/A compliant document as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty/invalid or PDF/A level is unsupported
|
||||
/// </exception>
|
||||
Task<byte[]> ConvertToPdfAAsync(Stream pdfStream, string pdfALevel);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a PDF/A document to a standard PDF (removes PDF/A restrictions).
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF/A document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>Standard PDF document as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty/invalid
|
||||
/// </exception>
|
||||
Task<byte[]> ConvertFromPdfAAsync(Stream pdfStream);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Codecrete.SwissQRBill.Generator;
|
||||
|
||||
namespace DocumentService.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for Swiss QR Code processing operations.
|
||||
/// Extracts and parses Swiss QR Codes from PDF documents.
|
||||
/// </summary>
|
||||
public interface ISwissQrCodeProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts and parses Swiss QR Code from a PDF document.
|
||||
/// Returns both parsed Bill object and raw QR text lines.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="pageNumbers">Optional: Specific page numbers to scan (1-indexed). If null, scans all pages starting with last page.</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Tuple: (Parsed Codecrete Bill, Raw QR lines as string array)</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.NotFoundException">
|
||||
/// Thrown when no Swiss QR Code is found in the specified pages
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when stream is empty or (for seekable streams) not positioned at the beginning
|
||||
/// </exception>
|
||||
Task<(Bill Bill, string[] RawLines)> ExtractSwissQrCodeAsync(
|
||||
Stream pdfStream,
|
||||
int[]? pageNumbers = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
48
DocumentService.Application/Common/Mapping/MappingProfile.cs
Normal file
48
DocumentService.Application/Common/Mapping/MappingProfile.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using AutoMapper;
|
||||
using Codecrete.SwissQRBill.Generator;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Domain.Models.ValueObjects;
|
||||
|
||||
namespace DocumentService.Application.Common.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// AutoMapper profile for mapping domain entities and external models to DTOs.
|
||||
/// NOTE: Always use AutoMapper for all mappings in this project.
|
||||
/// </summary>
|
||||
public class MappingProfile : Profile
|
||||
{
|
||||
public MappingProfile()
|
||||
{
|
||||
|
||||
// Codecrete Bill -> SwissQrBillDto
|
||||
CreateMap<Bill, SwissQrBillDto>()
|
||||
.ForMember(dest => dest.Version, opt => opt.MapFrom(src => src.Version.ToString()))
|
||||
.ForMember(dest => dest.Currency, opt => opt.MapFrom(src => src.Currency ?? "CHF"))
|
||||
.ForMember(dest => dest.Account, opt => opt.MapFrom(src => src.Account ?? string.Empty))
|
||||
.ForMember(dest => dest.ReferenceType, opt => opt.MapFrom(src => src.ReferenceType ?? Bill.ReferenceTypeNoRef));
|
||||
|
||||
// Codecrete Address -> AddressDto
|
||||
CreateMap<Address, AddressDto>()
|
||||
.ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.Type.ToString()))
|
||||
.ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Name ?? string.Empty))
|
||||
.ForMember(dest => dest.PostalCode, opt => opt.MapFrom(src => src.PostalCode ?? string.Empty))
|
||||
.ForMember(dest => dest.Town, opt => opt.MapFrom(src => src.Town ?? string.Empty))
|
||||
.ForMember(dest => dest.CountryCode, opt => opt.MapFrom(src => src.CountryCode ?? string.Empty))
|
||||
#pragma warning disable CS0618 // Suppress obsolete warning for AddressLine1/2 mapping
|
||||
.ForMember(dest => dest.AddressLine1, opt => opt.MapFrom(src => src.AddressLine1))
|
||||
.ForMember(dest => dest.AddressLine2, opt => opt.MapFrom(src => src.AddressLine2));
|
||||
#pragma warning restore CS0618
|
||||
|
||||
// Codecrete AlternativeScheme -> AlternativeSchemeDto
|
||||
CreateMap<AlternativeScheme, AlternativeSchemeDto>()
|
||||
.ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Name ?? string.Empty))
|
||||
.ForMember(dest => dest.Instruction, opt => opt.MapFrom(src => src.Instruction ?? string.Empty));
|
||||
|
||||
// AttachmentInfo -> AttachmentCheckResult
|
||||
CreateMap<AttachmentInfo, AttachmentCheckResult>();
|
||||
|
||||
// AttachmentMetadata -> AttachmentDto
|
||||
CreateMap<AttachmentMetadata, AttachmentDto>()
|
||||
.ForMember(dest => dest.Size, opt => opt.MapFrom(src => src.SizeBytes));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.ConvertFromPdfA;
|
||||
|
||||
/// <summary>
|
||||
/// Command to convert a PDF/A document to a standard PDF (removes PDF/A restrictions)
|
||||
/// </summary>
|
||||
public record ConvertFromPdfACommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF/A document stream. Must be positioned at the beginning (Position = 0).
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ConvertFromPdfACommand
|
||||
/// </summary>
|
||||
public class ConvertFromPdfACommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<ConvertFromPdfACommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(ConvertFromPdfACommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.ConvertFromPdfAAsync(request.PdfStream);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ConvertFromPdfACommand
|
||||
/// </summary>
|
||||
public class ConvertFromPdfACommandValidator : AbstractValidator<ConvertFromPdfACommand>
|
||||
{
|
||||
public ConvertFromPdfACommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.ConvertToPdfA;
|
||||
|
||||
/// <summary>
|
||||
/// Command to convert a standard PDF to PDF/A format
|
||||
/// </summary>
|
||||
public record ConvertToPdfACommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document stream. Must be positioned at the beginning (Position = 0).
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target PDF/A level (e.g., "PDF/A-1b", "PDF/A-2b", "PDF/A-3b")
|
||||
/// </summary>
|
||||
public required string PdfALevel { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ConvertToPdfACommand
|
||||
/// </summary>
|
||||
public class ConvertToPdfACommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<ConvertToPdfACommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(ConvertToPdfACommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.ConvertToPdfAAsync(request.PdfStream, request.PdfALevel);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ConvertToPdfACommand
|
||||
/// </summary>
|
||||
public class ConvertToPdfACommandValidator : AbstractValidator<ConvertToPdfACommand>
|
||||
{
|
||||
private static readonly string[] ValidPdfALevels =
|
||||
{
|
||||
"PDF/A-1b", "PDF/A-1a",
|
||||
"PDF/A-2b", "PDF/A-2u", "PDF/A-2a",
|
||||
"PDF/A-3b", "PDF/A-3u", "PDF/A-3a"
|
||||
};
|
||||
|
||||
public ConvertToPdfACommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
|
||||
RuleFor(x => x.PdfALevel)
|
||||
.NotEmpty()
|
||||
.WithMessage("PDF/A level is required")
|
||||
.Must(level => ValidPdfALevels.Contains(level, StringComparer.OrdinalIgnoreCase))
|
||||
.WithMessage($"Invalid PDF/A level. Valid values: {string.Join(", ", ValidPdfALevels)}");
|
||||
}
|
||||
}
|
||||
44
DocumentService.Application/DependencyInjection.cs
Normal file
44
DocumentService.Application/DependencyInjection.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DocumentService.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency Injection configuration for Application Layer
|
||||
/// </summary>
|
||||
public static class DependencyInjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers Application Layer services (MediatR, FluentValidation, AutoMapper, Behaviors)
|
||||
/// </summary>
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var assembly = typeof(DependencyInjection).Assembly;
|
||||
|
||||
// Read LuckyPennySoft license key from appsettings.json
|
||||
var licenseKey = configuration.GetValue<string>("LuckyPennySoftLicenseKey")
|
||||
?? throw new InvalidOperationException("LuckyPennySoftLicenseKey not found in configuration");
|
||||
|
||||
// Register MediatR (scannt Assembly nach Handlers)
|
||||
services.AddMediatR(config =>
|
||||
{
|
||||
config.LicenseKey = licenseKey;
|
||||
config.RegisterServicesFromAssembly(assembly);
|
||||
|
||||
// Pipeline Behaviors (Reihenfolge wichtig!)
|
||||
config.AddOpenBehavior(typeof(Common.Behaviors.ValidationBehavior<,>));
|
||||
config.AddOpenBehavior(typeof(Common.Behaviors.LoggingBehavior<,>));
|
||||
});
|
||||
|
||||
// Register FluentValidation (scannt Assembly nach Validators)
|
||||
services.AddValidatorsFromAssembly(assembly);
|
||||
|
||||
// Register AutoMapper (scannt Assembly nach Profiles)
|
||||
services.AddAutoMapper(cfg => {
|
||||
cfg.LicenseKey = licenseKey;
|
||||
}, typeof(Common.Mapping.MappingProfile));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="DependencyInjection\**" />
|
||||
<Compile Remove="Features\**" />
|
||||
<EmbeddedResource Remove="DependencyInjection\**" />
|
||||
<EmbeddedResource Remove="Features\**" />
|
||||
<None Remove="DependencyInjection\**" />
|
||||
<None Remove="Features\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
||||
<PackageReference Include="Codecrete.SwissQRBill.Generator" Version="3.4.0" />
|
||||
<PackageReference Include="FluentValidation" Version="12.1.1" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="MediatR" Version="14.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Common\Mappings\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DocumentService.Domain\DocumentService.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.ExtractPdfAttachments;
|
||||
|
||||
/// <summary>
|
||||
/// Command to extract all embedded files from a PDF document and return as ZIP archive.
|
||||
/// </summary>
|
||||
public record ExtractPdfAttachmentsCommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document stream. Must be positioned at the beginning (Position = 0).
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ExtractPdfAttachmentsCommand.
|
||||
/// Extracts all embedded files from PDF and returns as ZIP archive.
|
||||
/// </summary>
|
||||
public class ExtractPdfAttachmentsCommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<ExtractPdfAttachmentsCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(ExtractPdfAttachmentsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Delegate to infrastructure layer
|
||||
return await pdfProcessor.ExtractAttachmentsAsync(request.PdfStream);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ExtractPdfAttachmentsCommand.
|
||||
/// </summary>
|
||||
public class ExtractPdfAttachmentsCommandValidator : AbstractValidator<ExtractPdfAttachmentsCommand>
|
||||
{
|
||||
public ExtractPdfAttachmentsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using DocumentService.Application.Common.Configuration;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using DocumentService.Domain.Common.Exceptions;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DocumentService.Application.ExtractZugferd;
|
||||
|
||||
/// <summary>
|
||||
/// Command to extract ZUGFeRD XML from a PDF document
|
||||
/// </summary>
|
||||
public record ExtractZugferdCommand : IRequest<ZugferdExtractionResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document stream. Must be positioned at the beginning (Position = 0).
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ExtractZugferdCommand.
|
||||
/// Extracts ZUGFeRD XML from PDF and returns XML content
|
||||
/// </summary>
|
||||
public class ExtractZugferdCommandHandler(
|
||||
IPdfProcessor pdfProcessor,
|
||||
IOptions<ZugferdSettings> settings)
|
||||
: IRequestHandler<ExtractZugferdCommand, ZugferdExtractionResult>
|
||||
{
|
||||
private readonly ZugferdSettings _settings = settings.Value;
|
||||
|
||||
public async Task<ZugferdExtractionResult> Handle(ExtractZugferdCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get all attachments
|
||||
var attachmentInfo = await pdfProcessor.CheckAttachmentsAsync(request.PdfStream);
|
||||
|
||||
// Find ZUGFeRD XML file using configured names and patterns
|
||||
var zugferdAttachment = attachmentInfo.Attachments.FirstOrDefault(a =>
|
||||
_settings.ZugferdFileNames.Any(name =>
|
||||
a.FileName.Equals(name, StringComparison.OrdinalIgnoreCase)) ||
|
||||
_settings.ZugferdFileNamePatterns.Any(pattern =>
|
||||
a.FileName.Contains(pattern, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
if (zugferdAttachment == null)
|
||||
{
|
||||
throw new NotFoundException("ZUGFeRD XML not found in PDF attachments");
|
||||
}
|
||||
|
||||
// Reset stream position for extraction
|
||||
request.PdfStream.Position = 0;
|
||||
|
||||
// Extract all attachments as ZIP
|
||||
byte[] zipBytes = await pdfProcessor.ExtractAttachmentsAsync(request.PdfStream);
|
||||
|
||||
// Find ZUGFeRD XML in ZIP
|
||||
using var zipStream = new MemoryStream(zipBytes);
|
||||
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
|
||||
|
||||
var zugferdEntry = zipArchive.Entries.FirstOrDefault(e =>
|
||||
e.Name.Equals(zugferdAttachment.FileName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (zugferdEntry == null)
|
||||
{
|
||||
throw new NotFoundException($"ZUGFeRD XML '{zugferdAttachment.FileName}' not found in extracted attachments");
|
||||
}
|
||||
|
||||
// Read XML content
|
||||
using var entryStream = zugferdEntry.Open();
|
||||
using var reader = new StreamReader(entryStream);
|
||||
string xmlContent = await reader.ReadToEndAsync(cancellationToken);
|
||||
|
||||
return new ZugferdExtractionResult
|
||||
{
|
||||
FileName = zugferdAttachment.FileName,
|
||||
XmlContent = xmlContent,
|
||||
FileSize = zugferdAttachment.SizeBytes
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ExtractZugferdCommand.
|
||||
/// </summary>
|
||||
public class ExtractZugferdCommandValidator : AbstractValidator<ExtractZugferdCommand>
|
||||
{
|
||||
public ExtractZugferdCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result DTO for ZUGFeRD extraction
|
||||
/// </summary>
|
||||
public record ZugferdExtractionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// ZUGFeRD XML file name
|
||||
/// </summary>
|
||||
public required string FileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ZUGFeRD XML content as string
|
||||
/// </summary>
|
||||
public required string XmlContent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File size in bytes
|
||||
/// </summary>
|
||||
public long FileSize { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using DocumentService.Application.Common.Configuration;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DocumentService.Application.HasZugferd.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for checking if PDF contains ZUGFeRD XML attachment
|
||||
/// </summary>
|
||||
public record HasZugferdQuery : IRequest<ZugferdCheckResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for HasZugferdQuery
|
||||
/// Checks if PDF contains ZUGFeRD/Factur-X XML attachment
|
||||
/// </summary>
|
||||
public class HasZugferdQueryHandler(
|
||||
IPdfProcessor pdfProcessor,
|
||||
IOptions<ZugferdSettings> settings)
|
||||
: IRequestHandler<HasZugferdQuery, ZugferdCheckResult>
|
||||
{
|
||||
private readonly ZugferdSettings _settings = settings.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if PDF contains ZUGFeRD XML and returns metadata
|
||||
/// </summary>
|
||||
public async Task<ZugferdCheckResult> Handle(HasZugferdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get all attachments
|
||||
var attachmentInfo = await pdfProcessor.CheckAttachmentsAsync(request.PdfStream);
|
||||
|
||||
// Check for ZUGFeRD/Factur-X XML files using configured names and patterns
|
||||
var zugferdAttachment = attachmentInfo.Attachments.FirstOrDefault(a =>
|
||||
_settings.ZugferdFileNames.Any(name =>
|
||||
a.FileName.Equals(name, StringComparison.OrdinalIgnoreCase)) ||
|
||||
_settings.ZugferdFileNamePatterns.Any(pattern =>
|
||||
a.FileName.Contains(pattern, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
if (zugferdAttachment != null)
|
||||
{
|
||||
return new ZugferdCheckResult
|
||||
{
|
||||
HasZugferd = true,
|
||||
ZugferdFileName = zugferdAttachment.FileName,
|
||||
ZugferdFileSize = zugferdAttachment.SizeBytes,
|
||||
ZugferdMimeType = zugferdAttachment.MimeType
|
||||
};
|
||||
}
|
||||
|
||||
return new ZugferdCheckResult
|
||||
{
|
||||
HasZugferd = false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace DocumentService.Application.HasZugferd.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for HasZugferdQuery
|
||||
/// </summary>
|
||||
public class HasZugferdQueryValidator : AbstractValidator<HasZugferdQuery>
|
||||
{
|
||||
public HasZugferdQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
}
|
||||
}
|
||||
53
DocumentService.Application/MergePdfs/MergePdfsCommand.cs
Normal file
53
DocumentService.Application/MergePdfs/MergePdfsCommand.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.MergePdfs;
|
||||
|
||||
/// <summary>
|
||||
/// Command to merge multiple PDF documents into a single PDF.
|
||||
/// </summary>
|
||||
public record MergePdfsCommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF streams to merge. Minimum 2 required. Each must be at Position=0.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<Stream> PdfStreams { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional page ranges per PDF (null = all pages).
|
||||
/// Format: "1-3,5" means pages 1, 2, 3, and 5.
|
||||
/// If provided, array length must match PdfStreams length.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string?>? PageRanges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for MergePdfsCommand.
|
||||
/// Delegates PDF merge operation to infrastructure layer.
|
||||
/// </summary>
|
||||
public class MergePdfsCommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<MergePdfsCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(MergePdfsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.MergePdfsAsync(request.PdfStreams, request.PageRanges);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for MergePdfsCommand.
|
||||
/// </summary>
|
||||
public class MergePdfsCommandValidator : AbstractValidator<MergePdfsCommand>
|
||||
{
|
||||
public MergePdfsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStreams)
|
||||
.NotNull()
|
||||
.WithMessage("PDF streams are required");
|
||||
|
||||
RuleFor(x => x.PdfStreams)
|
||||
.Must(streams => streams != null && streams.Count >= 2)
|
||||
.WithMessage("At least 2 PDF files are required for merging");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using AutoMapper;
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.SwissQrCode.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for extracting Swiss QR Code from PDF (Stream-based)
|
||||
/// </summary>
|
||||
public record ExtractSwissQrCodeQuery : IRequest<SwissQrCodeExtractionResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ExtractSwissQrCodeQuery
|
||||
/// Orchestrates Swiss QR Code extraction using ISwissQrCodeProcessor and AutoMapper
|
||||
/// </summary>
|
||||
public class ExtractSwissQrCodeQueryHandler(ISwissQrCodeProcessor qrCodeProcessor, IMapper mapper)
|
||||
: IRequestHandler<ExtractSwissQrCodeQuery, SwissQrCodeExtractionResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts and parses Swiss QR Code from the PDF (default: scans all pages starting with last)
|
||||
/// Returns both parsed Bill DTO and raw QR text lines
|
||||
/// </summary>
|
||||
public async Task<SwissQrCodeExtractionResult> Handle(ExtractSwissQrCodeQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Extract: returns (Bill, RawLines) - pass stream directly
|
||||
var (bill, rawLines) = await qrCodeProcessor.ExtractSwissQrCodeAsync(request.PdfStream, pageNumbers: null, cancellationToken);
|
||||
|
||||
// Map Codecrete Bill to DTO using AutoMapper
|
||||
var billDto = mapper.Map<SwissQrBillDto>(bill);
|
||||
|
||||
// Return references (passed through) + Bill DTO + raw lines
|
||||
return new SwissQrCodeExtractionResult(
|
||||
Bill: billDto,
|
||||
RawLines: rawLines
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using DocumentService.Application.SwissQrCode.Queries;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DocumentService.Application.SwissQrCode.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Validates ExtractSwissQrCodeQuery before handler execution.
|
||||
/// Ensures PdfStream is not null.
|
||||
/// </summary>
|
||||
public sealed class ExtractSwissQrCodeQueryValidator : AbstractValidator<ExtractSwissQrCodeQuery>
|
||||
{
|
||||
public ExtractSwissQrCodeQueryValidator()
|
||||
{
|
||||
// Rule: PdfStream must be provided
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PdfStream is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.ValidatePdf.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for PDF validation (Stream-based)
|
||||
/// </summary>
|
||||
public record ValidatePdfQuery : IRequest<PdfValidationResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ValidatePdfQuery
|
||||
/// </summary>
|
||||
public class ValidatePdfQueryHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<ValidatePdfQuery, PdfValidationResult>
|
||||
{
|
||||
public async Task<PdfValidationResult> Handle(ValidatePdfQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.ValidateAsync(request.PdfStream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using DocumentService.Application.ValidatePdf.Queries;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DocumentService.Application.ValidatePdf.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ValidatePdfQuery
|
||||
/// Ensures PdfStream is not null
|
||||
/// </summary>
|
||||
public class ValidatePdfQueryValidator : AbstractValidator<ValidatePdfQuery>
|
||||
{
|
||||
public ValidatePdfQueryValidator()
|
||||
{
|
||||
// Rule: PdfStream must be provided
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PdfStream is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using DocumentService.Application.Common.DTOs;
|
||||
using DocumentService.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentService.Application.ValidatePdfA.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for PDF/A validation (Stream-based)
|
||||
/// </summary>
|
||||
public record ValidatePdfAQuery : IRequest<PdfAValidationResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ValidatePdfAQuery
|
||||
/// </summary>
|
||||
public class ValidatePdfAQueryHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<ValidatePdfAQuery, PdfAValidationResult>
|
||||
{
|
||||
public async Task<PdfAValidationResult> Handle(ValidatePdfAQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.ValidatePdfAAsync(request.PdfStream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace DocumentService.Application.ValidatePdfA.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ValidatePdfAQuery
|
||||
/// Ensures PdfStream is not null
|
||||
/// </summary>
|
||||
public class ValidatePdfAQueryValidator : AbstractValidator<Queries.ValidatePdfAQuery>
|
||||
{
|
||||
public ValidatePdfAQueryValidator()
|
||||
{
|
||||
// Rule: PdfStream must be provided
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PdfStream is required");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user