From 5dc9e686fc02c6d2bb5df4d324457b8c2928ac6f Mon Sep 17 00:00:00 2001 From: flfeders Date: Thu, 15 Oct 2026 13:05:06 +0200 Subject: [PATCH] fix(documents): restrict advance invoice creation to projects --- .../EntityShowSubCreatedDocuments.vue | 17 +++ .../components/copyCreatedDocumentModal.vue | 8 +- .../createDocumentFromTemplateModal.vue | 7 +- frontend/components/createDocumentModal.vue | 4 +- frontend/pages/createDocument/edit/[[id]].vue | 40 +++++- .../tests/advance-invoice-creation.test.cjs | 131 ++++++++++++++++++ frontend/tests/document-editor-units.test.cjs | 4 +- 7 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 frontend/tests/advance-invoice-creation.test.cjs diff --git a/frontend/components/EntityShowSubCreatedDocuments.vue b/frontend/components/EntityShowSubCreatedDocuments.vue index 40ebef86..fc643ef0 100644 --- a/frontend/components/EntityShowSubCreatedDocuments.vue +++ b/frontend/components/EntityShowSubCreatedDocuments.vue @@ -124,6 +124,16 @@ const invoiceDeliveryNotes = () => { router.push(`/createDocument/edit?type=invoices&loadMode=deliveryNotes&linkedDocuments=[${props.item.createddocuments.filter(i => deliveryNoteLikeDocumentTypes.includes(i.type)).map(i => i.id)}]`) } +const createAdvanceInvoice = () => { + if (props.topLevelType !== 'projects' || !props.item.id) return + + const query = new URLSearchParams(props.queryStringData) + query.set('type', 'advanceInvoices') + query.set('loadMode', 'advanceInvoice') + query.set('project', props.item.id) + router.push(`/createDocument/edit?${query.toString()}`) +} + const showFinalInvoiceConfig = ref(false) const referenceDocument = ref(null) const advanceInvoicesToAdd = ref([]) @@ -169,6 +179,13 @@ const selectItem = (item) => { > Dokument aus Vorlage + + Abschlagsrechnung + { - return Object.keys(dataStore.documentTypesForCreation).map((key) => ({ + return Object.keys(dataStore.documentTypesForCreation).filter(key => key !== 'advanceInvoices').map((key) => ({ ...dataStore.documentTypesForCreation[key], key })) @@ -83,6 +83,8 @@ const mappings = ref({ }) const startImport = () => { + if (!selectedDocumentType.value) return + router.push(`/createDocument/edit/?createddocument=${props.id}&type=${documentTypeToUse.value}&optionsToImport=${encodeURIComponent(JSON.stringify(optionsToImport.value))}`) modal.close() } @@ -147,7 +149,7 @@ const startImport = () => { Abbrechen - + Kopieren diff --git a/frontend/components/createDocumentFromTemplateModal.vue b/frontend/components/createDocumentFromTemplateModal.vue index 14319939..cc590696 100644 --- a/frontend/components/createDocumentFromTemplateModal.vue +++ b/frontend/components/createDocumentFromTemplateModal.vue @@ -10,10 +10,11 @@ const props = defineProps({ }) const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {}) - .filter(([key]) => key !== 'serialInvoices') + .filter(([key]) => !['serialInvoices', 'advanceInvoices'].includes(key)) .map(([key, value]) => ({ key, ...value }))) const visibleTemplates = computed(() => templates.value.filter(template => { - return !selectedType.value || template.documentType === selectedType.value + return template.documentType !== 'advanceInvoices' + && (!selectedType.value || template.documentType === selectedType.value) })) const loadTemplates = async () => { @@ -25,6 +26,8 @@ const loadTemplates = async () => { } const selectTemplate = (template) => { + if (template.documentType === 'advanceInvoices') return + const query = new URLSearchParams(props.queryStringData) query.set('templateId', template.id) query.set('type', template.documentType) diff --git a/frontend/components/createDocumentModal.vue b/frontend/components/createDocumentModal.vue index 729ceb29..dd273081 100644 --- a/frontend/components/createDocumentModal.vue +++ b/frontend/components/createDocumentModal.vue @@ -9,10 +9,12 @@ const props = defineProps({ }) const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {}) - .filter(([key]) => key !== 'serialInvoices') + .filter(([key]) => !['serialInvoices', 'advanceInvoices'].includes(key)) .map(([key, value]) => ({ key, ...value }))) const createDocument = (type) => { + if (!documentTypes.value.some(documentType => documentType.key === type)) return + const query = new URLSearchParams(props.queryStringData) query.set('type', type) if (props.projectId) query.set('project', props.projectId) diff --git a/frontend/pages/createDocument/edit/[[id]].vue b/frontend/pages/createDocument/edit/[[id]].vue index eaa80fc2..70d2576b 100644 --- a/frontend/pages/createDocument/edit/[[id]].vue +++ b/frontend/pages/createDocument/edit/[[id]].vue @@ -104,10 +104,36 @@ const bankaccounts = ref([]) const texttemplates = ref([]) const units = ref([]) const tenantUsers = ref([]) -const documentTypeItems = computed(() => Object.keys(dataStore.documentTypesForCreation).map((key) => ({ - label: dataStore.documentTypesForCreation[key].labelSingle, - type: key -}))) +const originalDocumentType = ref(null) +const canUseAdvanceInvoice = computed(() => originalDocumentType.value === 'advanceInvoices' || ( + route.query.type === 'advanceInvoices' + && route.query.loadMode === 'advanceInvoice' + && Number.isSafeInteger(Number(route.query.project)) + && Number(route.query.project) > 0 + && !route.params.id + && !route.query.createddocument + && !route.query.optionsToImport + && !route.query.linkedDocuments + && !route.query.templateId + && !isTemplateMode.value +)) +const documentTypeItems = computed(() => Object.keys(dataStore.documentTypesForCreation) + .filter(key => key !== 'advanceInvoices' || canUseAdvanceInvoice.value) + .map((key) => ({ + label: dataStore.documentTypesForCreation[key].labelSingle, + type: key + }))) +const validateAdvanceInvoiceCreation = () => { + if (itemInfo.value.type !== 'advanceInvoices' || isTemplateMode.value) return true + if (canUseAdvanceInvoice.value && (route.params.id || normalizeEntityId(itemInfo.value.project))) return true + + toast.add({ + title: 'Abschlagsrechnung nur im Projekt erstellen', + description: 'Bitte den Button „Abschlagsrechnung“ innerhalb eines Projekts verwenden.', + color: 'error' + }) + return false +} const taxTypeItems = [ { key: 'Standard', label: 'Standard' }, { key: '13b UStG', label: '13b UStG' }, @@ -391,6 +417,7 @@ const setupPage = async () => { if (route.params.id) { console.log(route.params) itemInfo.value = await useEntities("createddocuments").selectSingle(route.params.id,'',false) + originalDocumentType.value = itemInfo.value.type itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows) itemInfo.value.taxType = normalizeTaxTypeValue(itemInfo.value.taxType) await setContactPersonData() @@ -699,6 +726,10 @@ const setupPage = async () => { itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows) + if (!validateAdvanceInvoiceCreation()) { + await router.replace('/createDocument') + return + } loaded.value = true } @@ -1817,6 +1848,7 @@ const saveDocumentTemplate = async () => { } const saveDocument = async (state, resetup = false) => { + if (!validateAdvanceInvoiceCreation()) return itemInfo.value.state = state normalizeCustomSurchargePercentage() diff --git a/frontend/tests/advance-invoice-creation.test.cjs b/frontend/tests/advance-invoice-creation.test.cjs new file mode 100644 index 00000000..5eda4762 --- /dev/null +++ b/frontend/tests/advance-invoice-creation.test.cjs @@ -0,0 +1,131 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const vm = require('node:vm') +const ts = require('typescript') +const { parse, compileScript } = require('@vue/compiler-sfc') + +const documentTypesForCreation = Object.fromEntries([ + 'invoices', 'advanceInvoices', 'serialInvoices', 'quotes', 'costEstimates', + 'deliveryNotes', 'packingSlips', 'confirmationOrders', 'cancellationInvoices', +].map(key => [key, { labelSingle: key }])) + +function fixture(filename, names, globals = {}) { + const { descriptor } = parse(fs.readFileSync(`${__dirname}/../${filename}`, 'utf8')) + compileScript(descriptor, { id: filename, inlineTemplate: true }) + const script = ts.createSourceFile(filename, descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) + const source = script.statements.filter(statement => ts.isVariableStatement(statement) + && statement.declarationList.declarations.some(declaration => names.includes(declaration.name.getText(script)))) + .map(statement => statement.getText(script)).join('\n') + const navigations = [] + const notices = [] + const context = vm.createContext({ + ref: value => ({ value }), computed: getter => ({ get value() { return getter() } }), + dataStore: { documentTypesForCreation }, URLSearchParams, + router: { push: url => navigations.push(url), replace: url => navigations.push(url) }, + modal: { close() {} }, toast: { add: notice => notices.push(notice) }, + ...globals, + }) + vm.runInContext(source, context) + return { context, navigations, notices, run: code => vm.runInContext(code, context) } +} + +test('copying any document excludes advance invoices and requires an allowed target', () => { + for (const type of Object.keys(documentTypesForCreation)) { + const { run, navigations } = fixture('components/copyCreatedDocumentModal.vue', [ + 'documentTypeToUse', 'documentTypeItems', 'selectedDocumentType', 'optionsToImport', 'startImport', + ], { props: { id: '12', type } }) + assert.equal(run("documentTypeItems.value.some(item => item.key === 'advanceInvoices')"), false) + assert.equal(run('documentTypeToUse.value'), type === 'advanceInvoices' ? null : type) + run("documentTypeToUse.value = 'advanceInvoices'; startImport()") + assert.equal(navigations.length, 0) + run("documentTypeToUse.value = 'invoices'; startImport()") + const query = new URL(navigations[0], 'http://localhost').searchParams + assert.equal(query.get('type'), 'invoices') + assert.equal(query.get('createddocument'), '12') + } +}) + +test('+ Dokument excludes advance invoices even when opened inside a project', () => { + for (const queryStringData of ['', 'project=7&customer=8']) { + const { run, navigations } = fixture('components/createDocumentModal.vue', ['documentTypes', 'createDocument'], { + props: { queryStringData }, + }) + assert.equal(run("documentTypes.value.some(item => item.key === 'advanceInvoices')"), false) + run("createDocument('advanceInvoices')") + assert.equal(navigations.length, 0) + run("createDocument('quotes')") + assert.equal(new URL(navigations[0], 'http://localhost').searchParams.get('type'), 'quotes') + } +}) + +test('advance invoice templates cannot create outgoing documents', () => { + const { run, navigations } = fixture('components/createDocumentFromTemplateModal.vue', [ + 'templates', 'selectedType', 'documentTypes', 'visibleTemplates', 'selectTemplate', + ], { props: { queryStringData: 'project=7' } }) + run("templates.value = [{ id: 1, documentType: 'advanceInvoices' }, { id: 2, documentType: 'quotes' }]") + assert.equal(run("documentTypes.value.some(item => item.key === 'advanceInvoices')"), false) + assert.equal(run('visibleTemplates.value.length'), 1) + assert.equal(run('visibleTemplates.value[0].id'), 2) + run('selectTemplate(templates.value[0])') + assert.equal(navigations.length, 0) + run('selectTemplate(templates.value[1])') + assert.equal(new URL(navigations[0], 'http://localhost').searchParams.get('templateId'), '2') +}) + +function editorFixture(query = {}, id) { + return fixture('pages/createDocument/edit/[[id]].vue', [ + 'originalDocumentType', 'canUseAdvanceInvoice', 'documentTypeItems', 'normalizeEntityId', + 'validateAdvanceInvoiceCreation', 'saveDocument', + ], { + route: { params: { id }, query }, isTemplateMode: { value: false }, + itemInfo: { value: { type: 'advanceInvoices', project: 7 } }, + }) +} + +test('only the project action opens a new advance invoice with its project and customer', () => { + const { run, navigations, context } = fixture('components/EntityShowSubCreatedDocuments.vue', ['createAdvanceInvoice'], { + props: { topLevelType: 'projects', item: { id: 7 }, queryStringData: 'customer=8&project=99' }, + }) + run('createAdvanceInvoice()') + const query = Object.fromEntries(new URL(navigations[0], 'http://localhost').searchParams) + assert.equal(query.project, '7') + assert.equal(query.customer, '8') + const editor = editorFixture(query) + assert.equal(editor.run('validateAdvanceInvoiceCreation()'), true) + assert.equal(editor.run("documentTypeItems.value.some(item => item.type === 'advanceInvoices')"), true) + context.props.topLevelType = 'customers' + run('createAdvanceInvoice()') + assert.equal(navigations.length, 1) +}) + +test('editor rejects alternate creation routes and saving a switched document type', async () => { + const projectQuery = { type: 'advanceInvoices', loadMode: 'advanceInvoice', project: '7' } + for (const query of [ + {}, { type: 'advanceInvoices' }, { type: 'advanceInvoices', project: '7' }, + { ...projectQuery, project: '' }, { ...projectQuery, project: 'invalid' }, + { ...projectQuery, createddocument: '12' }, { ...projectQuery, optionsToImport: '{}' }, + { ...projectQuery, linkedDocuments: '[12]' }, { ...projectQuery, templateId: '5' }, + ]) { + const { run, notices, navigations } = editorFixture(query) + assert.equal(run("documentTypeItems.value.some(item => item.type === 'advanceInvoices')"), false) + assert.equal(run('validateAdvanceInvoiceCreation()'), false) + await run("saveDocument('Entwurf')") + assert.equal(notices.length, 2) + assert.equal(navigations.length, 0) + } +}) + +test('new advance invoices require a project; existing advance invoices remain editable', () => { + const fresh = editorFixture({ type: 'advanceInvoices', loadMode: 'advanceInvoice', project: '7' }) + fresh.run('itemInfo.value.project = null') + assert.equal(fresh.run('validateAdvanceInvoiceCreation()'), false) + const existing = editorFixture({}, '12') + existing.run("originalDocumentType.value = 'advanceInvoices'; itemInfo.value.project = null") + assert.equal(existing.run('validateAdvanceInvoiceCreation()'), true) + assert.equal(existing.run("documentTypeItems.value.some(item => item.type === 'advanceInvoices')"), true) + existing.run("originalDocumentType.value = 'invoices'") + assert.equal(existing.run('validateAdvanceInvoiceCreation()'), false) + existing.run("itemInfo.value.type = 'invoices'") + assert.equal(existing.run('validateAdvanceInvoiceCreation()'), true) +}) diff --git a/frontend/tests/document-editor-units.test.cjs b/frontend/tests/document-editor-units.test.cjs index e3122f0c..a66df2b7 100644 --- a/frontend/tests/document-editor-units.test.cjs +++ b/frontend/tests/document-editor-units.test.cjs @@ -8,7 +8,7 @@ const { parse } = require('@vue/compiler-sfc') const { descriptor } = parse(fs.readFileSync(`${__dirname}/../pages/createDocument/edit/[[id]].vue`, 'utf8')) const script = ts.createSourceFile('document-editor.js', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) -const handlers = new Set(['normalizeEntityId', 'normalizeUnitId', 'normalizeCreatedDocumentRow', 'normalizeCreatedDocumentRows', 'normalizeTaxTypeValue', 'setupPage', 'setRowData']) +const handlers = new Set(['normalizeEntityId', 'normalizeUnitId', 'normalizeCreatedDocumentRow', 'normalizeCreatedDocumentRows', 'normalizeTaxTypeValue', 'originalDocumentType', 'canUseAdvanceInvoice', 'validateAdvanceInvoiceCreation', 'setupPage', 'setRowData']) const source = script.statements .filter(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => handlers.has(declaration.name.getText(script)))) .map(statement => statement.getText(script)).join('\n') @@ -18,6 +18,8 @@ function fixture(unit) { const product = { id: 20, name: 'Artikel', unit, selling_price: 10, tax_percentage: 19 } const service = { id: 30, name: 'Leistung', unit, sellingPrice: 20, sellingPriceComposed: {}, taxPercentage: 19 } const context = vm.createContext({ + ref, computed: getter => ({ get value() { return getter() } }), + isTemplateMode: ref(false), dayjs, uuidv4: () => 'row-id', console: { log() {} }, products: ref([product]), services: ref([service]), productcategories: ref([]), servicecategories: ref([]),