Hvert værktøj jeg udgiver på AUR får sin egen side under /projekter/<navn>, så pakkernes Upstream URL kan pege på en side her i stedet for direkte på Gitea. Siderne ligger som markdown i en ny projekter-samling og bærer SoftwareSourceCode-data med kode-repo og AUR-pakker. Kortene på /projekter linker til projektsiden når der findes en, de tre står øverst, og de er med i ⌘K-søgningen. /ubl linker videre til ubl-tools. Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
200 lines
11 KiB
Plaintext
200 lines
11 KiB
Plaintext
---
|
|
import Base from '../layouts/Base.astro';
|
|
import { profiles } from '../data/profiles';
|
|
|
|
// Minimal data til klient-matchning (id + customization + navn + standard + dokumenter).
|
|
const idx = profiles.map((p) => ({
|
|
standard: p.standard,
|
|
navn: p.navn,
|
|
id: p.id,
|
|
customization: p.customization,
|
|
dokumenter: p.dokumenter,
|
|
}));
|
|
---
|
|
<Base
|
|
title="UBL-tjek & identifikator-afkoder"
|
|
description="Træk en UBL/OIOUBL-faktura ind eller indsæt en CustomizationID/ProfileID, så afkoder den profilen, syntaksen og EN 16931-status. Alt sker lokalt i browseren; filen sendes aldrig nogen steder."
|
|
>
|
|
<h1 class="text-4xl font-bold tracking-tight">UBL-tjek</h1>
|
|
<p class="mt-4 max-w-2xl text-muted">
|
|
Træk en <strong class="text-text">UBL/OIOUBL-faktura</strong> (eller andet UBL-dokument) ind, eller indsæt en
|
|
<strong class="text-text">CustomizationID/ProfileID</strong>, så afkoder den profilen, syntaksen og
|
|
EN 16931-status og kobler til <a href="/profiler" class="text-accent">profil-opslaget</a>.
|
|
Alt sker <strong class="text-text">lokalt i din browser</strong>; intet sendes nogen steder.
|
|
</p>
|
|
<p class="mt-3 max-w-2xl text-muted">
|
|
Vil du læse hele fakturaen eller have den vedhæftede PDF ud, så har jeg skrevet
|
|
<a href="/projekter/ubl-tools" class="text-accent hover:underline">ubl-tools</a> til terminalen,
|
|
også på Windows og macOS.
|
|
</p>
|
|
|
|
<div id="drop" class="mt-8 rounded-2xl border border-dashed border-border bg-surface/40 p-8 text-center transition-colors">
|
|
<p class="text-muted">Træk en <code class="rounded bg-surface-2 px-1.5 py-0.5">.xml</code>-fil hertil, eller
|
|
<label class="cursor-pointer text-accent hover:underline">vælg en fil<input id="file" type="file" accept=".xml,text/xml,application/xml" class="hidden" /></label>.
|
|
</p>
|
|
</div>
|
|
|
|
<details class="mt-3">
|
|
<summary class="cursor-pointer text-sm text-muted">…eller indsæt XML / en identifikator manuelt</summary>
|
|
<textarea id="paste" rows="6" placeholder="Indsæt UBL-XML, eller bare en CustomizationID/ProfileID…"
|
|
class="mt-2 w-full rounded-xl border border-border bg-surface/60 px-4 py-3 font-mono text-xs outline-none focus:border-accent"></textarea>
|
|
<p class="mt-2"><button id="analyze" type="button" class="rounded-xl bg-accent px-4 py-2 text-sm font-medium text-bg">Afkod</button></p>
|
|
</details>
|
|
|
|
<div id="result" class="mt-6" hidden></div>
|
|
<p id="err" class="mt-6 text-sm" hidden></p>
|
|
|
|
<p class="mt-10 text-xs text-muted/80">
|
|
Heuristik mod kendte profiler (samme datagrundlag som <a href="/profiler" class="text-accent">/profiler</a>) +
|
|
EN 16931-/Peppol-/OIOUBL-mønstre. Privat: ingen upload, ingen sporing af indholdet.
|
|
</p>
|
|
|
|
<script type="application/json" id="profiles-data" set:html={JSON.stringify(idx)}></script>
|
|
<script is:inline>
|
|
(function () {
|
|
var PROFILES = JSON.parse(document.getElementById('profiles-data').textContent || '[]');
|
|
var drop = document.getElementById('drop'), fileInput = document.getElementById('file'),
|
|
paste = document.getElementById('paste'), result = document.getElementById('result'),
|
|
err = document.getElementById('err');
|
|
var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
|
|
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); };
|
|
|
|
// Electronic Address Scheme (ISO 6523): de gængse, DK/Norden i fokus.
|
|
var EAS = {
|
|
'0088': 'GLN (GS1 Global Location Number)',
|
|
'0184': 'DK:CVR (dansk CVR-nummer)',
|
|
'0198': 'DK:SE (dansk SE-nummer)',
|
|
'0096': 'DK:P (dansk P-nummer)',
|
|
'0192': 'NO:ORG (norsk organisasjonsnummer)',
|
|
'0007': 'SE:ORG (svensk organisationsnummer)',
|
|
'0208': 'BE:CBE (belgisk virksomhedsnr.)',
|
|
'0151': 'AU:ABN', '0060': 'DUNS', '0235': 'JP', '0211': 'IT:PA',
|
|
'9915': 'Peppol test-deltager', '9930': 'DE:LWID (Leitweg-ID)',
|
|
};
|
|
|
|
function findEl(doc, name) {
|
|
var all = doc.getElementsByTagName('*');
|
|
for (var i = 0; i < all.length; i++) if (all[i].localName === name) return all[i];
|
|
return null;
|
|
}
|
|
function txt(doc, name) { var e = findEl(doc, name); return e ? (e.textContent || '').trim() : ''; }
|
|
|
|
// afkod en CustomizationID/ProfileID-streng til menneskesprog
|
|
function decode(custom, profile) {
|
|
var notes = [], standard = null, match = null;
|
|
var c = (custom || '').trim(), p = (profile || '').trim();
|
|
|
|
// 1) eksakt match mod kendte profiler
|
|
match = PROFILES.find(function (x) { return c && x.customization === c; })
|
|
|| PROFILES.find(function (x) { return p && x.id === p; })
|
|
|| PROFILES.find(function (x) { return c && x.id === c; });
|
|
|
|
// 2) mønster-heuristik (også når der ikke er fuldt match)
|
|
var blob = (c + ' ' + p).toLowerCase();
|
|
if (blob.indexOf('en16931') > -1) notes.push('EN 16931-kompatibel (CIUS af den europæiske kernefaktura)');
|
|
if (blob.indexOf('peppol.eu') > -1 || blob.indexOf('fdc:peppol') > -1) { standard = 'Peppol BIS'; notes.push('Peppol-netværket'); }
|
|
if (blob.indexOf('pint') > -1) { standard = 'Peppol PINT'; notes.push('PINT (Peppol International)'); }
|
|
if (blob.indexOf('billing:3.0') > -1) notes.push('Billing 3.0-proces');
|
|
if (blob.indexOf('selfbilling') > -1) notes.push('Selvfakturering (køber udsteder)');
|
|
if (blob.indexOf('oioubl') > -1) { standard = standard || 'OIOUBL (NemHandel)'; notes.push('OIOUBL, dansk UBL-tilpasning'); }
|
|
if (/procurement-/i.test(p)) { standard = standard || 'OIOUBL (NemHandel)'; if (/r-|r$|simr|advr/i.test(p)) notes.push("'R' = svar påkrævet"); }
|
|
|
|
return { match: match, standard: match ? match.standard : standard, notes: notes };
|
|
}
|
|
|
|
function badge(text, kind) {
|
|
var cls = kind === 'ok' ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-surface-2 text-text';
|
|
return '<span class="rounded-full border ' + cls + ' px-2.5 py-0.5 text-xs">' + esc(text) + '</span>';
|
|
}
|
|
function row(dt, dd) {
|
|
return '<div class="grid gap-1 sm:grid-cols-[10rem_1fr]"><dt class="text-muted">' + esc(dt) +
|
|
'</dt><dd class="break-all font-mono">' + (dd || '<span class="text-muted">—</span>') + '</dd></div>';
|
|
}
|
|
|
|
function renderXml(doc) {
|
|
var root = doc.documentElement;
|
|
var docType = root ? root.localName : '?';
|
|
var ublv = txt(doc, 'UBLVersionID');
|
|
var custom = txt(doc, 'CustomizationID');
|
|
var profile = txt(doc, 'ProfileID');
|
|
var id = txt(doc, 'ID');
|
|
var dec = decode(custom, profile);
|
|
|
|
// EAS på parter
|
|
var endpoints = [];
|
|
var eps = doc.getElementsByTagName('*');
|
|
for (var i = 0; i < eps.length; i++) {
|
|
if (eps[i].localName === 'EndpointID') {
|
|
var scheme = eps[i].getAttribute('schemeID') || '';
|
|
endpoints.push({ scheme: scheme, val: (eps[i].textContent || '').trim() });
|
|
}
|
|
}
|
|
var epHtml = endpoints.slice(0, 4).map(function (e) {
|
|
return '<div>' + badge((EAS[e.scheme] || ('EAS ' + e.scheme || 'ukendt')), '') +
|
|
' <span class="font-mono text-xs">' + esc(e.val) + '</span></div>';
|
|
}).join('');
|
|
|
|
var head = dec.match
|
|
? '<div class="flex flex-wrap items-center gap-2">' + badge(dec.match.standard, 'ok') +
|
|
'</div><h2 class="mt-2 text-xl font-semibold tracking-tight">' + esc(dec.match.navn) + '</h2>'
|
|
: '<h2 class="text-xl font-semibold tracking-tight">' + esc(docType) +
|
|
(dec.standard ? ' <span class="text-muted text-base font-normal">· ' + esc(dec.standard) + '</span>' : '') + '</h2>';
|
|
|
|
result.innerHTML =
|
|
'<div class="rounded-2xl border border-border bg-surface/60 p-5">' + head +
|
|
'<dl class="mt-3 space-y-1 text-xs">' +
|
|
row('Dokumenttype', '<span class="font-semibold">' + esc(docType) + '</span> (UBL ' + (esc(ublv) || '2.x') + ')') +
|
|
row('ID', esc(id)) +
|
|
row('ProfileID', esc(profile)) +
|
|
row('CustomizationID', esc(custom)) +
|
|
'</dl>' +
|
|
(dec.notes.length ? '<div class="mt-3 flex flex-wrap gap-1.5">' + dec.notes.map(function (n) { return badge(n, ''); }).join('') + '</div>' : '') +
|
|
(epHtml ? '<p class="mt-3 text-xs text-muted">Parter (EAS):</p><div class="mt-1 space-y-1">' + epHtml + '</div>' : '') +
|
|
(dec.match ? '<p class="mt-3 text-xs"><a class="text-accent hover:underline" href="/profiler?q=' + encodeURIComponent(dec.match.id) + '">Se profilen i /profiler ↗</a></p>'
|
|
: '<p class="mt-3 text-xs text-muted">Ingen eksakt match i profil-databasen, afkodet ud fra mønstre.</p>') +
|
|
'</div>';
|
|
result.hidden = false; err.hidden = true;
|
|
}
|
|
|
|
function renderId(s) {
|
|
var dec = decode(s, s);
|
|
result.innerHTML = '<div class="rounded-2xl border border-border bg-surface/60 p-5">' +
|
|
(dec.match ? '<div class="flex flex-wrap items-center gap-2">' + badge(dec.match.standard, 'ok') +
|
|
'</div><h2 class="mt-2 text-xl font-semibold tracking-tight">' + esc(dec.match.navn) + '</h2>'
|
|
: '<h2 class="text-xl font-semibold tracking-tight">Identifikator</h2>') +
|
|
'<dl class="mt-3 space-y-1 text-xs">' + row('Streng', esc(s)) + '</dl>' +
|
|
(dec.notes.length ? '<div class="mt-3 flex flex-wrap gap-1.5">' + dec.notes.map(function (n) { return badge(n, ''); }).join('') + '</div>'
|
|
: '<p class="mt-3 text-xs text-muted">Genkendte ingen kendte mønstre.</p>') +
|
|
(dec.match ? '<p class="mt-3 text-xs"><a class="text-accent hover:underline" href="/profiler?q=' + encodeURIComponent(dec.match.id) + '">Se profilen i /profiler ↗</a></p>' : '') +
|
|
'</div>';
|
|
result.hidden = false; err.hidden = true;
|
|
}
|
|
|
|
function analyze(text) {
|
|
text = (text || '').trim();
|
|
if (!text) return;
|
|
if (text.indexOf('<') === 0 || text.indexOf('<?xml') === 0) {
|
|
var doc = new DOMParser().parseFromString(text, 'application/xml');
|
|
if (doc.getElementsByTagName('parsererror').length) {
|
|
err.textContent = 'Kunne ikke parse XML.. er filen velformet?'; err.hidden = false; result.hidden = true; return;
|
|
}
|
|
renderXml(doc);
|
|
} else {
|
|
renderId(text);
|
|
}
|
|
}
|
|
|
|
// fil-input + drag/drop
|
|
function readFile(f) { var r = new FileReader(); r.onload = function () { analyze(String(r.result)); }; r.readAsText(f); }
|
|
fileInput.addEventListener('change', function () { if (fileInput.files[0]) readFile(fileInput.files[0]); });
|
|
['dragenter', 'dragover'].forEach(function (ev) { drop.addEventListener(ev, function (e) {
|
|
e.preventDefault(); drop.classList.add('border-accent'); }); });
|
|
['dragleave', 'drop'].forEach(function (ev) { drop.addEventListener(ev, function (e) {
|
|
e.preventDefault(); drop.classList.remove('border-accent'); }); });
|
|
drop.addEventListener('drop', function (e) { var f = e.dataTransfer && e.dataTransfer.files[0]; if (f) readFile(f); });
|
|
document.getElementById('analyze').addEventListener('click', function () { analyze(paste.value); });
|
|
paste.addEventListener('keydown', function (e) { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') analyze(paste.value); });
|
|
})();
|
|
</script>
|
|
</Base>
|