xinvoice/ts/formats/cii/cii.validator.ts
2025-04-03 15:53:08 +00:00

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;
}
}