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([]),