feat(import): detect and import the article register's Valuta column (#1183)
Fixes #1167. The register export gained a Valuta column in #1166 but the importer ignored it, so re-imported non-SEK articles silently became SEK, breaking the export -> edit -> re-import round-trip. - Column detector recognizes valuta/valutakod/currency (claimed before generic columns; no keyword collision with Momskod). - Parser normalizes to upper-case ISO shape, drops malformed codes with a file-level warning, and carries currency per row. - Execute route validates codes lazily against the currencies table (FK stays the backstop when the reference read fails), imports valid codes, defaults absent to SEK, and in merge mode only overwrites when the file explicitly carries a valid currency. - Edit step shows a muted currency marker next to non-SEK prices; manual column mapping offers Valuta. - Export docblock caveat removed. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
aead2bc1d1
commit
36a1df4f6b
@@ -180,3 +180,60 @@ describe('POST /api/import/articles/execute', () => {
|
||||
expect(body.data.warnings[0]).toContain('3999')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/import/articles/execute currency handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockFetchAllRows.mockResolvedValue([])
|
||||
mockCheckRevenueAccount.mockResolvedValue('ok')
|
||||
mockEnsureArticleNumber.mockResolvedValue('AUTO-1')
|
||||
})
|
||||
|
||||
it('imports a valid Valuta code and defaults missing to SEK', async () => {
|
||||
// First queued result: lazy currencies reference read.
|
||||
enqueue({ data: [{ code: 'SEK' }, { code: 'EUR' }, { code: 'USD' }] })
|
||||
enqueue({ data: { id: 'a1', name: 'EU-tjanst', article_number: 'A-1', currency: 'EUR' } })
|
||||
enqueue({ data: { id: 'a2', name: 'Svensk tjanst', article_number: 'A-2', currency: 'SEK' } })
|
||||
|
||||
const res = await POST(makeRequest({
|
||||
rows: [
|
||||
row({ name: 'EU-tjanst', article_number: 'A-1', currency: 'EUR' }),
|
||||
row({ row_index: 3, name: 'Svensk tjanst', article_number: 'A-2', currency: null }),
|
||||
],
|
||||
update_duplicates: false,
|
||||
}))
|
||||
const { status, body } = await parseJsonResponse<{ data: { created: number; warnings: string[] } }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.created).toBe(2)
|
||||
expect(body.data.warnings).toEqual([])
|
||||
|
||||
const inserts = mockSupabase.from.mock.calls.filter((c: unknown[]) => c[0] === 'articles')
|
||||
expect(inserts.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('drops a code missing from the currencies table with a warning', async () => {
|
||||
enqueue({ data: [{ code: 'SEK' }, { code: 'EUR' }] })
|
||||
enqueue({ data: { id: 'a1', name: 'X', article_number: 'A-1', currency: 'SEK' } })
|
||||
|
||||
const res = await POST(makeRequest({
|
||||
rows: [row({ article_number: 'A-1', currency: 'XXX' })],
|
||||
update_duplicates: false,
|
||||
}))
|
||||
const { status, body } = await parseJsonResponse<{ data: { warnings: string[] } }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.warnings.some((w) => w.includes('XXX'))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a malformed currency shape at validation', async () => {
|
||||
const res = await POST(makeRequest({
|
||||
rows: [row({ currency: 'EURO' })],
|
||||
update_duplicates: false,
|
||||
}))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,6 +75,29 @@ export const POST = withRouteContext(
|
||||
const accountStatusCache = new Map<string, RevenueAccountStatus>()
|
||||
const droppedAccounts = new Set<string>()
|
||||
const warnings: string[] = []
|
||||
|
||||
// Currency codes are validated against the currencies reference table
|
||||
// (the same set the articles.currency FK enforces): unknown codes are
|
||||
// dropped with a warning instead of failing the row on a FK violation.
|
||||
// Loaded lazily: most files carry no Valuta column at all. If the
|
||||
// reference read fails, codes pass through and the FK stays the backstop.
|
||||
let validCurrencies: Set<string> | null | undefined
|
||||
const droppedCurrencies = new Set<string>()
|
||||
const resolveCurrency = async (code: string | null | undefined): Promise<string | null> => {
|
||||
if (!code) return null
|
||||
if (validCurrencies === undefined) {
|
||||
const { data: currencyRows } = await supabase.from('currencies').select('code')
|
||||
validCurrencies = currencyRows
|
||||
? new Set((currencyRows as { code: string }[]).map((c) => c.code))
|
||||
: null
|
||||
}
|
||||
if (!validCurrencies || validCurrencies.has(code)) return code
|
||||
if (!droppedCurrencies.has(code)) {
|
||||
droppedCurrencies.add(code)
|
||||
warnings.push(`Valutan ${code} stöds inte, ignorerades: priset importerades som SEK.`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
const resolveRevenueAccount = async (acc: string | null): Promise<string | null> => {
|
||||
if (!acc) return null
|
||||
let status = accountStatusCache.get(acc)
|
||||
@@ -107,6 +130,7 @@ export const POST = withRouteContext(
|
||||
null
|
||||
|
||||
const revenueAccount = await resolveRevenueAccount(row.revenue_account)
|
||||
const currency = await resolveCurrency(row.currency)
|
||||
|
||||
if (match) {
|
||||
if (!update_duplicates) {
|
||||
@@ -126,6 +150,9 @@ export const POST = withRouteContext(
|
||||
if (row.housework_type) merged.housework_type = row.housework_type
|
||||
if (row.notes) merged.notes = row.notes
|
||||
if (revenueAccount) merged.revenue_account = revenueAccount
|
||||
// Only when the file explicitly carries a valid currency: absence
|
||||
// must never reset an existing non-SEK article to SEK.
|
||||
if (currency) merged.currency = currency
|
||||
|
||||
if (Object.keys(merged).length === 0) {
|
||||
skipped++
|
||||
@@ -159,6 +186,7 @@ export const POST = withRouteContext(
|
||||
type: row.type,
|
||||
unit: row.unit || 'st',
|
||||
price_excl_vat: row.price_excl_vat,
|
||||
currency: currency ?? 'SEK',
|
||||
vat_rate: row.vat_rate,
|
||||
revenue_account: revenueAccount,
|
||||
cost_price: row.cost_price,
|
||||
|
||||
Reference in New Issue
Block a user