feat(invoicing): artikelregister (product/article catalog) with per-article revenue account (#703)
* feat(invoicing): artikelregister (product/article catalog) with per-article revenue account Add a lean, non-inventory article catalog (artikelregister) so users can define reusable invoice-line presets (name, unit, price excl VAT, VAT rate) with an optional per-article BAS class-3 revenue-account override. - DB: articles table (RLS via user_company_ids(), audit + updated_at triggers, unique-per-company article_number), generate_article_number RPC (atomic + idempotent), company_settings counter, nullable invoice_items.revenue_account + article_id, pending_operations CHECK expansion. - Engine: generatePerRateLines groups revenue by (vat_rate, account) — byte-identical with no override, balance-safe when split (last account absorbs the rounding remainder), reverse_charge/export still force 3308/3305. - API: /api/articles CRUD (soft-deactivate); override validated against chart_of_accounts (active class-3) and frozen onto invoice lines at create. - Propagation: override carried through send/mark-sent/credit/convert/cash and the staged commit paths (recurring deferred — documented inline). - MCP: gnubok_list/create/update_article (staged, scoped, risk-tiered). - UI: articles register (list/detail/form) + nav + bilingual i18n + invoice-line article picker & "Spara som artikel" quick-create. - Tests: engine regression, route, and pg-real (RPC/RLS/triggers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): strip ILIKE _ wildcard from gnubok_list_articles search Underscore is a single-character ILIKE wildcard; stripping it (alongside the existing %,()\* set) keeps a stray char in the article search from matching every row. Read-only + RLS-scoped, so no security impact — addresses PR #703 reviewer + compliance-swarm CC6.3 notes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
07ecb98389
commit
c0b006fcc1
@@ -2789,6 +2789,187 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// ── Article tools (artikelregister) ──────────────────────────
|
||||
|
||||
{
|
||||
name: 'gnubok_list_articles',
|
||||
title: 'List Articles',
|
||||
description: "List the active company's catalog articles (artikelregister). Use to look up an article to add to an invoice line. Active articles only by default.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Optional case-insensitive filter on name or article_number.' },
|
||||
include_inactive: { type: 'boolean', description: 'Include deactivated articles (default false).' },
|
||||
},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
articles: { type: 'array', items: { type: 'object' } },
|
||||
count: { type: 'number' },
|
||||
},
|
||||
required: ['articles', 'count'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
let q = supabase
|
||||
.from('articles')
|
||||
.select('id, article_number, name, name_en, type, unit, price_excl_vat, vat_rate, revenue_account, housework_type, active')
|
||||
.eq('company_id', companyId)
|
||||
if (!args.include_inactive) q = q.eq('active', true)
|
||||
|
||||
// Strip PostgREST filter metacharacters before interpolating into .or() —
|
||||
// commas/parens would otherwise let a query inject extra or-conditions, and
|
||||
// the ILIKE wildcards % and _ would turn a stray char into a match-all.
|
||||
const raw = typeof args.query === 'string' ? args.query : ''
|
||||
const safe = raw.replace(/[%_,()\\*]/g, ' ').trim()
|
||||
if (safe) {
|
||||
q = q.or(`name.ilike.%${safe}%,article_number.ilike.%${safe}%`)
|
||||
}
|
||||
|
||||
const { data, error } = await q.order('name')
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
return { articles: data, count: data?.length ?? 0 }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_create_article',
|
||||
title: 'Create Article',
|
||||
description: 'Stage a new catalog article (artikelregister). Stages for approval — not created until approved. Article number auto-assigned. Reuse on invoice lines via gnubok_create_invoice.',
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Article name (prints on the invoice line).' },
|
||||
type: { type: 'string', enum: ['vara', 'tjanst'], description: 'Good (vara) or service (tjanst). Default tjanst.' },
|
||||
unit: { type: 'string', description: 'Unit, e.g. st, tim, kg. Default st.' },
|
||||
price_excl_vat: { type: 'number', description: 'Unit price EXCLUDING VAT.' },
|
||||
vat_rate: { type: 'number', enum: [0, 6, 12, 25], description: 'VAT rate percent. Default 25.' },
|
||||
revenue_account: { type: 'string', description: 'Optional BAS class-3 revenue account (e.g. 3041). Omit to derive from VAT.' },
|
||||
cost_price: { type: 'number', description: 'Optional cost price (margin only; never booked).' },
|
||||
ean: { type: 'string', description: 'Barcode / EAN.' },
|
||||
housework_type: { type: 'string', description: 'ROT/RUT arbetstyp (services only).' },
|
||||
name_en: { type: 'string', description: 'English name for English-language invoices.' },
|
||||
notes: { type: 'string' },
|
||||
article_number: { type: 'string', description: 'Optional manual number; omit to auto-generate.' },
|
||||
dry_run: { type: 'boolean', description: 'Validate and preview without staging.' },
|
||||
idempotency_key: { type: 'string', description: 'Per-operation UUID for safe retries (24h TTL).' },
|
||||
},
|
||||
required: ['name', 'price_excl_vat'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const name = (args.name as string)?.trim()
|
||||
if (!name) throw new Error('Article name is required.')
|
||||
if (typeof args.price_excl_vat !== 'number') {
|
||||
throw new Error('price_excl_vat is required and must be a number.')
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
name,
|
||||
type: (args.type as string) || 'tjanst',
|
||||
unit: (args.unit as string) || undefined,
|
||||
price_excl_vat: args.price_excl_vat,
|
||||
vat_rate: typeof args.vat_rate === 'number' ? args.vat_rate : 25,
|
||||
revenue_account: (args.revenue_account as string) || null,
|
||||
cost_price: typeof args.cost_price === 'number' ? args.cost_price : null,
|
||||
ean: (args.ean as string) || null,
|
||||
housework_type: (args.housework_type as string) || null,
|
||||
name_en: (args.name_en as string) || null,
|
||||
notes: (args.notes as string) || null,
|
||||
article_number: (args.article_number as string) || null,
|
||||
}
|
||||
|
||||
return stagePendingOperation(supabase, companyId, userId, 'create_article',
|
||||
`Ny artikel: ${name}`,
|
||||
params,
|
||||
params, // params ARE the preview
|
||||
actor,
|
||||
{
|
||||
description: 'Once approved, add it to an invoice with gnubok_create_invoice using the returned article fields.',
|
||||
tool: 'gnubok_create_invoice',
|
||||
},
|
||||
{
|
||||
dryRun: Boolean(args.dry_run),
|
||||
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_update_article',
|
||||
title: 'Update Article',
|
||||
description: 'Stage an edit to a catalog article (price, name, account, etc.) or deactivate it via active:false. Stages for approval. Find article_id with gnubok_list_articles.',
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
article_id: { type: 'string', description: 'UUID of the article to update.' },
|
||||
name: { type: 'string' },
|
||||
type: { type: 'string', enum: ['vara', 'tjanst'] },
|
||||
unit: { type: 'string' },
|
||||
price_excl_vat: { type: 'number' },
|
||||
vat_rate: { type: 'number', enum: [0, 6, 12, 25] },
|
||||
revenue_account: { type: 'string', description: 'BAS class-3 revenue account, or omit to leave unchanged.' },
|
||||
cost_price: { type: 'number' },
|
||||
ean: { type: 'string' },
|
||||
housework_type: { type: 'string' },
|
||||
name_en: { type: 'string' },
|
||||
notes: { type: 'string' },
|
||||
active: { type: 'boolean', description: 'Set false to deactivate (hide from pickers, keep history).' },
|
||||
dry_run: { type: 'boolean' },
|
||||
idempotency_key: { type: 'string' },
|
||||
},
|
||||
required: ['article_id'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const articleId = args.article_id as string
|
||||
if (!articleId) throw new Error('article_id is required.')
|
||||
|
||||
const params: Record<string, unknown> = { article_id: articleId }
|
||||
for (const key of [
|
||||
'name', 'type', 'unit', 'price_excl_vat', 'vat_rate', 'revenue_account',
|
||||
'cost_price', 'ean', 'housework_type', 'name_en', 'notes', 'active',
|
||||
]) {
|
||||
if (args[key] !== undefined) params[key] = args[key]
|
||||
}
|
||||
|
||||
return stagePendingOperation(supabase, companyId, userId, 'update_article',
|
||||
`Uppdatera artikel ${(args.name as string)?.trim() || articleId}`,
|
||||
params,
|
||||
params,
|
||||
actor,
|
||||
undefined,
|
||||
{
|
||||
dryRun: Boolean(args.dry_run),
|
||||
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
// ── Invoice tools ────────────────────────────────────────────
|
||||
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user