173 lines
4.9 KiB
TypeScript
173 lines
4.9 KiB
TypeScript
import { BaseValidator } from '../base/base.validator.js';
|
|
import { ValidationLevel } from '../../interfaces/common.js';
|
|
import type { ValidationResult } from '../../interfaces/common.js';
|
|
import { CII_NAMESPACES, CIIProfile } from './cii.types.js';
|
|
import { DOMParser } from 'xmldom';
|
|
import * as xpath from 'xpath';
|
|
|
|
/**
|
|
* Base validator for CII-based invoice formats
|
|
*/
|
|
export abstract class CIIBaseValidator extends BaseValidator {
|
|
protected doc: Document;
|
|
protected namespaces: Record<string, string>;
|
|
protected select: xpath.XPathSelect;
|
|
protected profile: CIIProfile = CIIProfile.EN16931;
|
|
|
|
constructor(xml: string) {
|
|
super(xml);
|
|
|
|
try {
|
|
// Parse XML document
|
|
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
|
|
|
|
// Set up namespaces for XPath queries
|
|
this.namespaces = {
|
|
rsm: CII_NAMESPACES.RSM,
|
|
ram: CII_NAMESPACES.RAM,
|
|
udt: CII_NAMESPACES.UDT
|
|
};
|
|
|
|
// Create XPath selector with namespaces
|
|
this.select = xpath.useNamespaces(this.namespaces);
|
|
|
|
// Detect profile
|
|
this.detectProfile();
|
|
} catch (error) {
|
|
this.addError('CII-PARSE', `Failed to parse XML: ${error}`, '/');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates CII XML against the specified level of validation
|
|
* @param level Validation level
|
|
* @returns Result of validation
|
|
*/
|
|
public validate(level: ValidationLevel = ValidationLevel.SYNTAX): ValidationResult {
|
|
// Reset errors
|
|
this.errors = [];
|
|
|
|
// Check if document was parsed successfully
|
|
if (!this.doc) {
|
|
return {
|
|
valid: false,
|
|
errors: this.errors,
|
|
level: level
|
|
};
|
|
}
|
|
|
|
// Perform validation based on level
|
|
let valid = true;
|
|
|
|
if (level === ValidationLevel.SYNTAX) {
|
|
valid = this.validateSchema();
|
|
} else if (level === ValidationLevel.SEMANTIC) {
|
|
valid = this.validateSchema() && this.validateStructure();
|
|
} else if (level === ValidationLevel.BUSINESS) {
|
|
valid = this.validateSchema() &&
|
|
this.validateStructure() &&
|
|
this.validateBusinessRules();
|
|
}
|
|
|
|
return {
|
|
valid,
|
|
errors: this.errors,
|
|
level
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validates CII XML against schema
|
|
* @returns True if schema validation passed
|
|
*/
|
|
protected validateSchema(): boolean {
|
|
// Basic schema validation (simplified for now)
|
|
if (!this.doc) return false;
|
|
|
|
// Check for root element
|
|
const root = this.doc.documentElement;
|
|
if (!root || root.nodeName !== 'rsm:CrossIndustryInvoice') {
|
|
this.addError('CII-SCHEMA-1', 'Root element must be rsm:CrossIndustryInvoice', '/');
|
|
return false;
|
|
}
|
|
|
|
// Check for required namespaces
|
|
if (!root.lookupNamespaceURI('rsm') || !root.lookupNamespaceURI('ram')) {
|
|
this.addError('CII-SCHEMA-2', 'Required namespaces rsm and ram must be declared', '/');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Validates structure of the CII XML document
|
|
* @returns True if structure validation passed
|
|
*/
|
|
protected abstract validateStructure(): boolean;
|
|
|
|
/**
|
|
* Detects the CII profile from the XML
|
|
*/
|
|
protected detectProfile(): void {
|
|
// Look for profile identifier
|
|
const profileNode = this.select(
|
|
'string(//rsm:ExchangedDocumentContext/ram:GuidelineSpecifiedDocumentContextParameter/ram:ID)',
|
|
this.doc
|
|
);
|
|
|
|
if (profileNode) {
|
|
const profileText = profileNode.toString();
|
|
|
|
if (profileText.includes('BASIC')) {
|
|
this.profile = CIIProfile.BASIC;
|
|
} else if (profileText.includes('EN16931')) {
|
|
this.profile = CIIProfile.EN16931;
|
|
} else if (profileText.includes('EXTENDED')) {
|
|
this.profile = CIIProfile.EXTENDED;
|
|
} else if (profileText.includes('MINIMUM')) {
|
|
this.profile = CIIProfile.MINIMUM;
|
|
} else if (profileText.includes('COMFORT')) {
|
|
this.profile = CIIProfile.COMFORT;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets a text value from an XPath expression
|
|
* @param xpath XPath expression
|
|
* @param context Optional context node
|
|
* @returns Text value or empty string if not found
|
|
*/
|
|
protected getText(xpathExpr: string, context?: Node): string {
|
|
const node = this.select(xpathExpr, context || this.doc)[0];
|
|
return node ? (node.textContent || '') : '';
|
|
}
|
|
|
|
/**
|
|
* Gets a number value from an XPath expression
|
|
* @param xpath XPath expression
|
|
* @param context Optional context node
|
|
* @returns Number value or 0 if not found or not a number
|
|
*/
|
|
protected getNumber(xpathExpr: string, context?: Node): number {
|
|
const text = this.getText(xpathExpr, context);
|
|
const num = parseFloat(text);
|
|
return isNaN(num) ? 0 : num;
|
|
}
|
|
|
|
/**
|
|
* Checks if a node exists
|
|
* @param xpath XPath expression
|
|
* @param context Optional context node
|
|
* @returns True if node exists
|
|
*/
|
|
protected exists(xpathExpr: string, context?: Node): boolean {
|
|
const nodes = this.select(xpathExpr, context || this.doc);
|
|
if (Array.isArray(nodes)) {
|
|
return nodes.length > 0;
|
|
}
|
|
return false;
|
|
}
|
|
}
|