Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.4.0
1.5.0
18 changes: 18 additions & 0 deletions cloud/backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ def _add_column_if_missing(table: str, column: str, ddl_sqlite: str, ddl_other:
conn.execute(text(stmt))


def _patch_article_label_length() -> None:
"""Truncate article labels to 21 chars (station receipt bon text limit)."""
try:
inspector = inspect(engine)
if "articles" not in inspector.get_table_names():
return
except Exception:
return
with engine.begin() as conn:
if engine.dialect.name == "sqlite":
conn.execute(text("UPDATE articles SET label = SUBSTR(label, 1, 21) WHERE LENGTH(label) > 21"))
else:
conn.execute(
text("UPDATE articles SET label = SUBSTRING(label FROM 1 FOR 21) WHERE char_length(label) > 21")
)


def apply_schema_patches() -> None:
"""create_all() does not add columns to existing tables; patch known drift here."""
_add_column_if_missing(
Expand Down Expand Up @@ -322,6 +339,7 @@ def apply_schema_patches() -> None:
"ALTER TABLE articles ADD COLUMN tax_code_id INTEGER",
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS tax_code_id INTEGER",
)
_patch_article_label_length()
_patch_edge_order_item_fiscal_columns()
_patch_edge_order_items_ordered_at()
_patch_edge_operational_snapshot_tables()
Expand Down
2 changes: 1 addition & 1 deletion cloud/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ class Article(Base):
__tablename__ = "articles"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
label = Column(String(22), nullable=False)
label = Column(String(21), nullable=False)
price = Column(Float, nullable=False)
import_article_number = Column(String, nullable=True)
description = Column(Text, nullable=True)
Expand Down
4 changes: 2 additions & 2 deletions cloud/backend/app/orderjutsu_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ def collect_sellable_refs(parsed: OjParsedPayload, bom: OjBomResult | None = Non

def _article_label(name: str) -> str:
text = (name or "").strip()
return text[:22] if len(text) > 22 else text
return text[:21] if len(text) > 21 else text


def _import_number(ref: int) -> str:
Expand All @@ -233,7 +233,7 @@ def _normalize_match_texts(*values: str) -> list[str]:
text = (value or "").strip().lower()
if not text:
continue
for variant in (text, text[:22] if len(text) > 22 else None):
for variant in (text, text[:21] if len(text) > 21 else None):
if variant and variant not in seen:
seen.add(variant)
out.append(variant)
Expand Down
4 changes: 2 additions & 2 deletions cloud/backend/app/routers/articles.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

class ArticleBase(BaseModel):
name: str = Field(..., min_length=1)
label: str = Field(..., min_length=1, max_length=22)
label: str = Field(..., min_length=1, max_length=21)
price: float
import_article_number: str | None = None
description: str | None = None
Expand All @@ -44,7 +44,7 @@ class ArticleCreate(ArticleBase):

class ArticleUpdate(BaseModel):
name: str | None = Field(None, min_length=1)
label: str | None = Field(None, min_length=1, max_length=22)
label: str | None = Field(None, min_length=1, max_length=21)
price: float | None = None
import_article_number: str | None = None
description: str | None = None
Expand Down
27 changes: 27 additions & 0 deletions cloud/backend/tests/test_articles_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,3 +618,30 @@ def test_delete_article_blocked_lists_multiple_reasons():
"auf Stationen",
"Statistiken",
)


def test_article_label_max_length_21():
headers, _, cat_id, _ = _article_setup()
ok = client.post(
"/articles/",
headers=headers,
json={
"name": "Long catalog name",
"label": "1" * 21,
"price": 1.0,
"article_category_id": cat_id,
},
)
assert ok.status_code == 200, ok.text

too_long = client.post(
"/articles/",
headers=headers,
json={
"name": "Another",
"label": "2" * 22,
"price": 1.0,
"article_category_id": cat_id,
},
)
assert too_long.status_code == 422, too_long.text
6 changes: 3 additions & 3 deletions cloud/frontend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10256,7 +10256,7 @@
},
"label": {
"type": "string",
"maxLength": 22,
"maxLength": 21,
"minLength": 1,
"title": "Label"
},
Expand Down Expand Up @@ -10474,7 +10474,7 @@
},
"label": {
"type": "string",
"maxLength": 22,
"maxLength": 21,
"minLength": 1,
"title": "Label"
},
Expand Down Expand Up @@ -10637,7 +10637,7 @@
"anyOf": [
{
"type": "string",
"maxLength": 22,
"maxLength": 21,
"minLength": 1
},
{
Expand Down
10 changes: 8 additions & 2 deletions cloud/frontend/src/components/Articles.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@
hide-details="auto"
required
:rules="[rules.required]"
@blur="onNameBlur"
/>
</div>

<div class="form-field">
<v-text-field
v-model="form.label"
:label="$t('common.label')"
maxlength="22"
maxlength="21"
:placeholder="$t('articles.labelPlaceholder')"
hide-details="auto"
required
Expand Down Expand Up @@ -354,6 +355,7 @@ import { useClientPagination } from '../composables/useClientPagination'
import { invalidateOrgCatalog } from '../composables/useOrgCatalog'
import { matchesActiveOrganisation, organisationAccountsEnabled, organisationIngredientsEnabled } from '../utils/orgScope'
import { filterArticleList } from '../utils/articleListFilters'
import { labelFromNameIfEmpty } from '../utils/articleLabelFromName'
import { articleListHeaders } from '../utils/orgScopedListTableHeaders'
import { rules, validateForm } from '../utils/formRules.js'
import { formatPriceWithCurrency } from '../utils/localeFormat.js'
Expand Down Expand Up @@ -548,9 +550,13 @@ const formRef = ref(null)

const labelRules = computed(() => [
rules.required,
(v: string) => String(v || '').length <= 22 || t('articles.labelMaxLength'),
(v: string) => String(v || '').length <= 21 || t('articles.labelMaxLength'),
])

function onNameBlur() {
form.value.label = labelFromNameIfEmpty(form.value.name, form.value.label)
}

const priceRules = computed(() => {
const base = [rules.requiredNumber]
if (!form.value.isAddition) base.push(rules.minNumber(0))
Expand Down
140 changes: 140 additions & 0 deletions cloud/frontend/src/components/EventConfigVouchersSection.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import EventConfigVouchersSection from './EventConfigVouchersSection.vue'
import type { EventVoucherDefinitionLocal } from '@/types/ui'
import type { ArticleRead } from '@/types/api'
import { vuetifyStubs } from '../../tests/helpers/vuetifyStub.js'

function article(overrides: Partial<ArticleRead> & Pick<ArticleRead, 'id'>): ArticleRead {
return {
name: 'Item',
label: 'ITEM',
price: 1,
article_category_id: 1,
is_addition: false,
is_active: true,
article_category_name: 'Cat',
organisation_id: 1,
organisation_name: 'Org',
organisation_currency: 'CHF',
...overrides,
}
}

const sampleArticles: ArticleRead[] = [
article({ id: 10, name: 'Beer', label: 'B1', article_category_name: 'Drinks' }),
article({ id: 20, name: 'Wine', label: 'W1', article_category_name: 'Drinks' }),
]

const globalMount = {
global: {
stubs: {
...vuetifyStubs(),
FormLabel: { template: '<label><slot /></label>' },
'v-select': { template: '<select />', props: ['modelValue', 'items'] },
'v-number-input': {
template: '<input type="number" :value="modelValue" />',
props: ['modelValue'],
},
'v-checkbox': { template: '<input type="checkbox" />', props: ['modelValue'] },
StationArticleTransferPicker: {
template:
'<div data-testid="article-transfer-picker" :data-articles-count="articles.length"><button data-testid="add-article" type="button" @click="$emit(\'update:modelValue\', [42])">add</button></div>',
props: ['modelValue', 'articles', 'loading', 'disabled'],
},
'v-btn': {
template: '<button type="button" @click="$emit(\'click\')"><slot /></button>',
props: ['icon', 'variant', 'color', 'disabled'],
},
},
},
}

function articleEntitlementVoucher(): EventVoucherDefinitionLocal {
return {
uuid: 'vd-1',
name: 'Free beer',
kind: 'article_entitlement',
value_amount: 0,
allowed_article_ids: [],
include_additions: true,
}
}

describe('EventConfigVouchersSection', () => {
it('shows catalog loading hint', () => {
const wrapper = mount(EventConfigVouchersSection, {
props: {
modelValue: [],
catalogLoading: true,
catalogError: '',
articles: [],
currencyLabel: 'CHF',
voucherKindOptions: [],
},
...globalMount,
})
expect(wrapper.text()).toContain('Artikel und Kellner werden geladen')
})

it('renders article transfer picker only for article_entitlement vouchers', () => {
const wrapper = mount(EventConfigVouchersSection, {
props: {
modelValue: [
{
uuid: 'vd-fixed',
name: 'CHF 10',
kind: 'fixed_amount',
value_amount: 10,
allowed_article_ids: [],
include_additions: true,
},
articleEntitlementVoucher(),
],
catalogLoading: false,
catalogError: '',
articles: sampleArticles,
currencyLabel: 'CHF',
voucherKindOptions: [],
},
...globalMount,
})
expect(wrapper.findAll('[data-testid="article-transfer-picker"]')).toHaveLength(1)
})

it('passes event-scoped articles to the transfer picker', () => {
const wrapper = mount(EventConfigVouchersSection, {
props: {
modelValue: [articleEntitlementVoucher()],
catalogLoading: false,
catalogError: '',
articles: [sampleArticles[0]],
currencyLabel: 'CHF',
voucherKindOptions: [],
},
...globalMount,
})
const picker = wrapper.find('[data-testid="article-transfer-picker"]')
expect(picker.attributes('data-articles-count')).toBe('1')
})

it('binds article transfer picker to allowed_article_ids', async () => {
const vouchers: EventVoucherDefinitionLocal[] = [articleEntitlementVoucher()]
const wrapper = mount(EventConfigVouchersSection, {
props: {
modelValue: vouchers,
'onUpdate:modelValue': (v: EventVoucherDefinitionLocal[]) => {
vouchers.splice(0, vouchers.length, ...v)
},
catalogLoading: false,
catalogError: '',
articles: sampleArticles,
currencyLabel: 'CHF',
voucherKindOptions: [],
},
...globalMount,
})
await wrapper.find('[data-testid="add-article"]').trigger('click')
expect(vouchers[0].allowed_article_ids).toEqual([42])
})
})
26 changes: 12 additions & 14 deletions cloud/frontend/src/components/EventConfigVouchersSection.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<template>
<div class="event-config-vouchers-section">
<p v-if="catalogLoading" class="muted catalog-loading-hint">{{ $t('events.config.catalogLoading') }}</p>
<p v-else-if="catalogError" class="error">{{ catalogError }}</p>
<div class="section-toolbar">
<v-btn color="primary" type="button" @click="addVoucher">{{ $t('events.config.addVoucher') }}</v-btn>
</div>
Expand Down Expand Up @@ -46,19 +48,11 @@
<template v-else>
<div class="form-field">
<label>{{ $t('events.config.eligibleArticles') }}</label>
<v-select
<StationArticleTransferPicker
v-model="vd.allowed_article_ids"
:items="articleOptions"
item-title="name"
item-value="value"
:placeholder="$t('events.config.selectArticles')"
:articles="articles"
:loading="catalogLoading"
:disabled="catalogLoading"
multiple
chips
closable-chips
density="compact"
hide-details
:disabled="catalogLoading || !!catalogError"
/>
</div>
<v-checkbox
Expand All @@ -74,20 +68,24 @@

<script setup lang="ts">
import FormLabel from './FormLabel.vue'
import StationArticleTransferPicker from './StationArticleTransferPicker.vue'
import { rules } from '../utils/formRules.js'
import { newUuid } from '@/utils/newUuid'
import type { ArticleSelectOption, EventVoucherDefinitionLocal, SelectOption } from '@/types/ui'
import type { ArticleRead } from '@/types/api'
import type { EventVoucherDefinitionLocal, SelectOption } from '@/types/ui'

withDefaults(
defineProps<{
articleOptions?: ArticleSelectOption[]
articles?: ArticleRead[]
catalogLoading?: boolean
catalogError?: string
currencyLabel?: string
voucherKindOptions?: SelectOption<string>[]
}>(),
{
articleOptions: () => [],
articles: () => [],
catalogLoading: false,
catalogError: '',
currencyLabel: 'EUR',
voucherKindOptions: () => [],
},
Expand Down
Loading
Loading