Kostenstellenhierarchie und Auswertung mit Unterkostenstellen ergänzt
This commit is contained in:
2
backend/db/migrations/0033_costcentres_parent.sql
Normal file
2
backend/db/migrations/0033_costcentres_parent.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "costcentres" ADD COLUMN "parent_costcentre" uuid;
|
||||||
|
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_parent_costcentre_costcentres_id_fk" FOREIGN KEY ("parent_costcentre") REFERENCES "public"."costcentres"("id") ON DELETE no action ON UPDATE no action;
|
||||||
@@ -232,6 +232,13 @@
|
|||||||
"when": 1776298800000,
|
"when": 1776298800000,
|
||||||
"tag": "0032_manual_statementallocations_invoice_side",
|
"tag": "0032_manual_statementallocations_invoice_side",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 33,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777003200000,
|
||||||
|
"tag": "0033_costcentres_parent",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export const costcentres = pgTable("costcentres", {
|
|||||||
number: text("number").notNull(),
|
number: text("number").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
|
|
||||||
|
parentCostcentre: uuid("parent_costcentre").references(() => costcentres.id),
|
||||||
|
|
||||||
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
|
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
|
||||||
|
|
||||||
project: bigint("project", { mode: "number" }).references(() => projects.id),
|
project: bigint("project", { mode: "number" }).references(() => projects.id),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
|
||||||
import { authProfiles } from "../../../db/schema";
|
import { authProfiles, costcentres } from "../../../db/schema";
|
||||||
import { resourceConfig } from "../../utils/resource.config";
|
import { resourceConfig } from "../../utils/resource.config";
|
||||||
import { useNextNumberRangeNumber } from "../../utils/functions";
|
import { useNextNumberRangeNumber } from "../../utils/functions";
|
||||||
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
|
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
|
||||||
@@ -260,6 +260,59 @@ function validateMemberPayload(payload: Record<string, any>) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function validateCostCentreParent(
|
||||||
|
server: FastifyInstance,
|
||||||
|
tenantId: number,
|
||||||
|
costCentreId: string | null,
|
||||||
|
parentCostcentreId: string | null
|
||||||
|
) {
|
||||||
|
if (!parentCostcentreId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const hierarchyRows = await server.db
|
||||||
|
.select({
|
||||||
|
id: costcentres.id,
|
||||||
|
parentCostcentre: costcentres.parentCostcentre,
|
||||||
|
})
|
||||||
|
.from(costcentres)
|
||||||
|
.where(eq(costcentres.tenant, tenantId))
|
||||||
|
|
||||||
|
const hierarchyMap = new Map(
|
||||||
|
hierarchyRows.map((row) => [row.id, row.parentCostcentre || null])
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hierarchyMap.has(parentCostcentreId)) {
|
||||||
|
return "Die übergeordnete Kostenstelle wurde nicht gefunden."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (costCentreId && parentCostcentreId === costCentreId) {
|
||||||
|
return "Eine Kostenstelle kann nicht sich selbst als übergeordnete Kostenstelle haben."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!costCentreId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentParentId: string | null = parentCostcentreId
|
||||||
|
const visited = new Set<string>()
|
||||||
|
|
||||||
|
while (currentParentId) {
|
||||||
|
if (currentParentId === costCentreId) {
|
||||||
|
return "Die ausgewählte übergeordnete Kostenstelle würde einen Zyklus erzeugen."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visited.has(currentParentId)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(currentParentId)
|
||||||
|
currentParentId = hierarchyMap.get(currentParentId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function maskIban(iban: string) {
|
function maskIban(iban: string) {
|
||||||
if (!iban) return ""
|
if (!iban) return ""
|
||||||
const cleaned = iban.replace(/\s+/g, "")
|
const cleaned = iban.replace(/\s+/g, "")
|
||||||
@@ -730,6 +783,19 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
createData = prepared.data!
|
createData = prepared.data!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resource === "costcentres") {
|
||||||
|
const validationError = await validateCostCentreParent(
|
||||||
|
server,
|
||||||
|
req.user.tenant_id,
|
||||||
|
null,
|
||||||
|
createData.parentCostcentre || null
|
||||||
|
)
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
return reply.code(400).send({ error: validationError })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
||||||
const numberRangeResource = resource === "members" ? "customers" : resource
|
const numberRangeResource = resource === "members" ? "customers" : resource
|
||||||
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
|
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
|
||||||
@@ -836,6 +902,21 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resource === "costcentres") {
|
||||||
|
const validationError = await validateCostCentreParent(
|
||||||
|
server,
|
||||||
|
tenantId,
|
||||||
|
oldRecord.id,
|
||||||
|
Object.prototype.hasOwnProperty.call(data, "parentCostcentre")
|
||||||
|
? data.parentCostcentre || null
|
||||||
|
: oldRecord.parentCostcentre || null
|
||||||
|
)
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
return reply.code(400).send({ error: validationError })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
const value = data[key]
|
const value = data[key]
|
||||||
const shouldNormalize =
|
const shouldNormalize =
|
||||||
|
|||||||
@@ -164,9 +164,13 @@ export const resourceConfig = {
|
|||||||
costcentres: {
|
costcentres: {
|
||||||
table: costcentres,
|
table: costcentres,
|
||||||
searchColumns: ["name","number","description"],
|
searchColumns: ["name","number","description"],
|
||||||
mtoLoad: ["vehicle","project","inventoryitem","branch"],
|
mtoLoad: ["parentCostcentre","vehicle","project","inventoryitem","branch"],
|
||||||
numberRangeHolder: "number",
|
numberRangeHolder: "number",
|
||||||
},
|
},
|
||||||
|
parentCostcentre: {
|
||||||
|
table: costcentres,
|
||||||
|
searchColumns: ["name", "number", "description"],
|
||||||
|
},
|
||||||
branches: {
|
branches: {
|
||||||
table: branches,
|
table: branches,
|
||||||
searchColumns: ["name","number","description"],
|
searchColumns: ["name","number","description"],
|
||||||
|
|||||||
32
frontend/components/columnRenderings/costcentre.vue
Normal file
32
frontend/components/columnRenderings/costcentre.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
row: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
default: {}
|
||||||
|
},
|
||||||
|
inShow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const value = computed(() => {
|
||||||
|
const costcentre = props.row.parentCostcentre || props.row.costcentre || props.row.costCentre || null
|
||||||
|
|
||||||
|
if (!costcentre) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return [costcentre.number, costcentre.name].filter(Boolean).join(" - ")
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="props.row.parentCostcentre">
|
||||||
|
<nuxt-link v-if="props.inShow" :to="`/standardEntity/costcentres/show/${props.row.parentCostcentre.id}`">
|
||||||
|
{{ value }}
|
||||||
|
</nuxt-link>
|
||||||
|
<span v-else>{{ value }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -10,11 +10,57 @@ const props = defineProps({
|
|||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const incomingInvoices = ref([])
|
const incomingInvoices = ref([])
|
||||||
|
const costcentres = ref([])
|
||||||
const selectedYear = ref(String(dayjs().year()))
|
const selectedYear = ref(String(dayjs().year()))
|
||||||
const selectedMonth = ref("all")
|
const selectedMonth = ref("all")
|
||||||
|
|
||||||
const currency = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} EUR`
|
const currency = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} EUR`
|
||||||
|
|
||||||
|
const costCentreMap = computed(() => {
|
||||||
|
return new Map(costcentres.value.map((costcentre) => [costcentre.id, costcentre]))
|
||||||
|
})
|
||||||
|
|
||||||
|
const relevantCostCentreIds = computed(() => {
|
||||||
|
const rootId = props.item?.id
|
||||||
|
|
||||||
|
if (!rootId) {
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
const childrenByParent = new Map()
|
||||||
|
|
||||||
|
costcentres.value.forEach((costcentre) => {
|
||||||
|
if (!costcentre.parentCostcentre) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!childrenByParent.has(costcentre.parentCostcentre)) {
|
||||||
|
childrenByParent.set(costcentre.parentCostcentre, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
childrenByParent.get(costcentre.parentCostcentre).push(costcentre.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const collectedIds = new Set([rootId])
|
||||||
|
const queue = [rootId]
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const currentId = queue.shift()
|
||||||
|
const childIds = childrenByParent.get(currentId) || []
|
||||||
|
|
||||||
|
childIds.forEach((childId) => {
|
||||||
|
if (collectedIds.has(childId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
collectedIds.add(childId)
|
||||||
|
queue.push(childId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectedIds
|
||||||
|
})
|
||||||
|
|
||||||
const yearItems = computed(() => {
|
const yearItems = computed(() => {
|
||||||
const years = [...new Set(
|
const years = [...new Set(
|
||||||
incomingInvoices.value
|
incomingInvoices.value
|
||||||
@@ -29,7 +75,7 @@ const monthItems = [
|
|||||||
{ label: "Ganzes Jahr", value: "all" },
|
{ label: "Ganzes Jahr", value: "all" },
|
||||||
{ label: "Januar", value: "1" },
|
{ label: "Januar", value: "1" },
|
||||||
{ label: "Februar", value: "2" },
|
{ label: "Februar", value: "2" },
|
||||||
{ label: "Maerz", value: "3" },
|
{ label: "März", value: "3" },
|
||||||
{ label: "April", value: "4" },
|
{ label: "April", value: "4" },
|
||||||
{ label: "Mai", value: "5" },
|
{ label: "Mai", value: "5" },
|
||||||
{ label: "Juni", value: "6" },
|
{ label: "Juni", value: "6" },
|
||||||
@@ -53,12 +99,15 @@ const reportRows = computed(() => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchingAccounts = (invoice.accounts || []).filter((account) => account.costCentre === props.item.id)
|
const matchingAccounts = (invoice.accounts || []).filter((account) =>
|
||||||
|
relevantCostCentreIds.value.has(account.costCentre)
|
||||||
|
)
|
||||||
|
|
||||||
return matchingAccounts.map((account, index) => {
|
return matchingAccounts.map((account, index) => {
|
||||||
const amountNet = Number(account.amountNet || 0)
|
const amountNet = Number(account.amountNet || 0)
|
||||||
const amountTax = Number(account.amountTax || 0)
|
const amountTax = Number(account.amountTax || 0)
|
||||||
const amountGross = Number(account.amountGross || amountNet + amountTax || 0)
|
const amountGross = Number(account.amountGross || amountNet + amountTax || 0)
|
||||||
|
const accountCostCentre = costCentreMap.value.get(account.costCentre)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${invoice.id}-${index}`,
|
id: `${invoice.id}-${index}`,
|
||||||
@@ -68,6 +117,7 @@ const reportRows = computed(() => {
|
|||||||
state: invoice.state || "-",
|
state: invoice.state || "-",
|
||||||
vendorName: invoice.vendor?.name || "-",
|
vendorName: invoice.vendor?.name || "-",
|
||||||
accountLabel: account.account?.label || account.accountLabel || "-",
|
accountLabel: account.account?.label || account.accountLabel || "-",
|
||||||
|
costCentreName: accountCostCentre ? `${accountCostCentre.number} - ${accountCostCentre.name}` : "-",
|
||||||
description: account.description || invoice.description || "-",
|
description: account.description || invoice.description || "-",
|
||||||
amountNet,
|
amountNet,
|
||||||
amountTax,
|
amountTax,
|
||||||
@@ -91,6 +141,7 @@ const columns = [
|
|||||||
{ accessorKey: "date", header: "Datum" },
|
{ accessorKey: "date", header: "Datum" },
|
||||||
{ accessorKey: "vendorName", header: "Lieferant" },
|
{ accessorKey: "vendorName", header: "Lieferant" },
|
||||||
{ accessorKey: "accountLabel", header: "Konto" },
|
{ accessorKey: "accountLabel", header: "Konto" },
|
||||||
|
{ accessorKey: "costCentreName", header: "Kostenstelle" },
|
||||||
{ accessorKey: "description", header: "Beschreibung" },
|
{ accessorKey: "description", header: "Beschreibung" },
|
||||||
{ accessorKey: "amountNet", header: "Netto" },
|
{ accessorKey: "amountNet", header: "Netto" },
|
||||||
{ accessorKey: "amountTax", header: "Steuer" },
|
{ accessorKey: "amountTax", header: "Steuer" },
|
||||||
@@ -100,10 +151,11 @@ const columns = [
|
|||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
|
costcentres.value = await useEntities("costcentres").select("*", null, false, true)
|
||||||
const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)")
|
const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)")
|
||||||
|
|
||||||
incomingInvoices.value = invoices.filter((invoice) =>
|
incomingInvoices.value = invoices.filter((invoice) =>
|
||||||
(invoice.accounts || []).some((account) => account.costCentre === props.item.id)
|
(invoice.accounts || []).some((account) => relevantCostCentreIds.value.has(account.costCentre))
|
||||||
)
|
)
|
||||||
|
|
||||||
const firstYear = yearItems.value[0]?.value
|
const firstYear = yearItems.value[0]?.value
|
||||||
@@ -162,7 +214,7 @@ setupPage()
|
|||||||
v-if="!loading"
|
v-if="!loading"
|
||||||
:data="reportRows"
|
:data="reportRows"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Eingangsbelege mit dieser Kostenstelle gefunden' }"
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Eingangsbelege mit dieser Kostenstelle oder ihren Unterkostenstellen gefunden' }"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<template #reference-cell="{ row }">
|
<template #reference-cell="{ row }">
|
||||||
@@ -181,6 +233,10 @@ setupPage()
|
|||||||
<div class="truncate">{{ row.original.accountLabel }}</div>
|
<div class="truncate">{{ row.original.accountLabel }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #costCentreName-cell="{ row }">
|
||||||
|
<div class="truncate">{{ row.original.costCentreName }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #description-cell="{ row }">
|
<template #description-cell="{ row }">
|
||||||
<UTooltip :text="row.original.description">
|
<UTooltip :text="row.original.description">
|
||||||
<div class="max-w-[18rem] truncate">{{ row.original.description }}</div>
|
<div class="max-w-[18rem] truncate">{{ row.original.description }}</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import description from "~/components/columnRenderings/description.vue"
|
|||||||
import purchasePrice from "~/components/columnRenderings/purchasePrice.vue";
|
import purchasePrice from "~/components/columnRenderings/purchasePrice.vue";
|
||||||
import project from "~/components/columnRenderings/project.vue";
|
import project from "~/components/columnRenderings/project.vue";
|
||||||
import branch from "~/components/columnRenderings/branch.vue";
|
import branch from "~/components/columnRenderings/branch.vue";
|
||||||
|
import costcentre from "~/components/columnRenderings/costcentre.vue";
|
||||||
import created_at from "~/components/columnRenderings/created_at.vue";
|
import created_at from "~/components/columnRenderings/created_at.vue";
|
||||||
import profile from "~/components/columnRenderings/profile.vue";
|
import profile from "~/components/columnRenderings/profile.vue";
|
||||||
import profiles from "~/components/columnRenderings/profiles.vue";
|
import profiles from "~/components/columnRenderings/profiles.vue";
|
||||||
@@ -3312,7 +3313,7 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
numberRangeHolder: "number",
|
numberRangeHolder: "number",
|
||||||
historyItemHolder: "costcentre",
|
historyItemHolder: "costcentre",
|
||||||
sortColumn: "number",
|
sortColumn: "number",
|
||||||
selectWithInformation: "*, project(*), vehicle(*), inventoryitem(*), branch(*)",
|
selectWithInformation: "*, parentCostcentre(*), project(*), vehicle(*), inventoryitem(*), branch(*)",
|
||||||
filters: [{
|
filters: [{
|
||||||
name: "Archivierte ausblenden",
|
name: "Archivierte ausblenden",
|
||||||
default: true,
|
default: true,
|
||||||
@@ -3344,6 +3345,18 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
label: "Beschreibung",
|
label: "Beschreibung",
|
||||||
inputType: "textarea"
|
inputType: "textarea"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "parentCostcentre",
|
||||||
|
label: "Übergeordnete Kostenstelle",
|
||||||
|
component: costcentre,
|
||||||
|
inputType: "select",
|
||||||
|
selectDataType: "costcentres",
|
||||||
|
selectOptionAttribute: "name",
|
||||||
|
selectSearchAttributes: ["name", "number"],
|
||||||
|
selectDataTypeFilter: function (option, item) {
|
||||||
|
return option.id !== item.value?.id
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "vehicle",
|
key: "vehicle",
|
||||||
label: "Fahrzeug",
|
label: "Fahrzeug",
|
||||||
|
|||||||
Reference in New Issue
Block a user