From 0c5ba054f2c3e8653bce4dd87a0e83af2d2f676a Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Mon, 21 Sep 2026 16:07:49 +0800 Subject: [PATCH 1/8] feat(service-api)!: add UoM convert, partner find/lookup, User.LanguageId Same-category UoM.Convert, session-company Partner.FindOrCreate/NameCreate, and PartnerIdentifier.Lookup. User.Language becomes a LanguageId relation, with a best-effort upgrade backfill from the old code column. --- modules/auth/data/bootstrap.json | 2 +- modules/auth/e2e/fixtures/smoke.json | 4 +- modules/auth/service/hook/post_upgrade.ts | 40 +++++++++ modules/auth/service/index.ts | 2 + .../models/user/_lifecycle_auth.test.ts | 2 +- .../service/models/user/_session_envelopes.ts | 2 +- modules/auth/service/models/user/user.ts | 48 +++++++---- .../service/tests/bootstrap_gift_pack.test.ts | 2 +- .../tests/register_company_scope.test.ts | 6 +- .../auth/service/tests/user_timezone.test.ts | 12 ++- .../preferences/OPreferencesDialog.vue | 36 ++++++-- .../preferences/preferences_defaults.ts | 2 +- modules/auth/web/pages/Login.vue | 14 +++- modules/auth/web/stores/auth/actions.ts | 29 +++++-- modules/auth/web/views/UserFormView.vue | 2 +- modules/base/service/models/_uom_convert.ts | 81 ++++++++++++++++++ modules/base/service/models/uom.ts | 16 ++++ .../base/service/tests/uom_convert.test.ts | 72 ++++++++++++++++ .../web/views/language_merge.mapping.test.ts | 5 +- modules/core/service/i18n/request_lang.ts | 2 +- modules/partner/service/models/partner.ts | 83 +++++++++++++++++++ .../service/tests/find_or_create.test.ts | 61 ++++++++++++++ .../service/models/partner_identifier.ts | 43 ++++++++++ .../tests/partner_identifier_lookup.test.ts | 66 +++++++++++++++ modules/web/web/components/layout/OHeader.vue | 2 +- 25 files changed, 583 insertions(+), 51 deletions(-) create mode 100644 modules/auth/service/hook/post_upgrade.ts create mode 100644 modules/base/service/models/_uom_convert.ts create mode 100644 modules/base/service/tests/uom_convert.test.ts create mode 100644 modules/partner/service/tests/find_or_create.test.ts create mode 100644 modules/partner_commercial/service/tests/partner_identifier_lookup.test.ts diff --git a/modules/auth/data/bootstrap.json b/modules/auth/data/bootstrap.json index e178e5ea6..7bbb8e458 100644 --- a/modules/auth/data/bootstrap.json +++ b/modules/auth/data/bootstrap.json @@ -835,7 +835,7 @@ [ "Name", "=", - "Language" + "LanguageId" ], [ "ModelId", diff --git a/modules/auth/e2e/fixtures/smoke.json b/modules/auth/e2e/fixtures/smoke.json index 18f084e4a..b9a944d29 100644 --- a/modules/auth/e2e/fixtures/smoke.json +++ b/modules/auth/e2e/fixtures/smoke.json @@ -19,7 +19,9 @@ "ref": "base.e2e_company_child" } ], - "Language": "zh_CN", + "LanguageId": { + "ref": "base.language_zh_cn" + }, "Timezone": "UTC" } }, diff --git a/modules/auth/service/hook/post_upgrade.ts b/modules/auth/service/hook/post_upgrade.ts new file mode 100644 index 000000000..70aed6e70 --- /dev/null +++ b/modules/auth/service/hook/post_upgrade.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { HookPostUpgrade } from '@/core/service/api/model'; + +/** + * Copy leftover auth_user.language POSIX codes into language_id. + * The old varchar column is not dropped by schema sync; missing columns are ignored. + */ +export class AuthUserLanguageHooks { + @HookPostUpgrade() + static async backfillUserLanguageId(): Promise { + const db = (globalThis as { $choysum?: { db?: { dialectName?: string; execute?: (sql: string, params: string) => Promise } } }).$choysum?.db; + if (!db?.execute || !db.dialectName) return; + const dialect = String(db.dialectName); + let sql = ''; + if (dialect === 'sqlite') { + sql = `UPDATE auth_user + SET language_id = ( + SELECT id FROM base_language WHERE code = auth_user.language LIMIT 1 + ) + WHERE (language_id IS NULL OR language_id = '') + AND language IS NOT NULL + AND trim(language) <> ''`; + } else if (dialect === 'postgres' || dialect === 'mysql') { + sql = `UPDATE auth_user + SET language_id = ( + SELECT id FROM base_language WHERE code = auth_user.language LIMIT 1 + ) + WHERE COALESCE(language_id, '') = '' + AND COALESCE(language, '') <> ''`; + } + if (!sql) return; + try { + await db.execute(sql, '[]'); + } catch { + // Old language column may already be absent; users can set LanguageId again. + } + } +} diff --git a/modules/auth/service/index.ts b/modules/auth/service/index.ts index 847b0d8dd..09b8c944e 100644 --- a/modules/auth/service/index.ts +++ b/modules/auth/service/index.ts @@ -4,3 +4,5 @@ export * from './models'; export * from './mixins'; export * from './error'; + +import './hook/post_upgrade'; diff --git a/modules/auth/service/models/user/_lifecycle_auth.test.ts b/modules/auth/service/models/user/_lifecycle_auth.test.ts index a64dda418..8d445bec1 100644 --- a/modules/auth/service/models/user/_lifecycle_auth.test.ts +++ b/modules/auth/service/models/user/_lifecycle_auth.test.ts @@ -18,7 +18,7 @@ function loginStub(overrides: { }): User { const stub = Object.assign(Object.create(User.prototype), { Timezone: null, - Language: null, + LanguageId: null, CompanyId: '', CompanyIds: [], Preferences: {}, diff --git a/modules/auth/service/models/user/_session_envelopes.ts b/modules/auth/service/models/user/_session_envelopes.ts index dfe871579..29d05617a 100644 --- a/modules/auth/service/models/user/_session_envelopes.ts +++ b/modules/auth/service/models/user/_session_envelopes.ts @@ -27,7 +27,7 @@ const REGISTER_USER_KEYS = [ 'Email', 'FirstName', 'LastName', - 'Language', + 'LanguageId', 'Timezone', ] as const satisfies readonly (keyof Insertable)[]; diff --git a/modules/auth/service/models/user/user.ts b/modules/auth/service/models/user/user.ts index 6aba81f89..8a88ece7f 100644 --- a/modules/auth/service/models/user/user.ts +++ b/modules/auth/service/models/user/user.ts @@ -14,7 +14,7 @@ import Role from '../role'; import Token from '../token'; import UserRole from '../user_role'; import { parseModelFullName, parseServiceFullName } from '@/core/service/utils/model_parsing'; -import { uniqStrings } from '@/core/service/utils/normalization'; +import { uniqStrings, normalizeRefId } from '@/core/service/utils/normalization'; import { isIanaTimezone, listIanaTimezoneSelection } from '@/core/service/utils/datetime'; import { Constraint } from '@/core/service/api/constraint'; import type LanguageModel from '@/base/service/models/language'; @@ -189,17 +189,20 @@ export default class User extends AttachmentOwnerMixin { Avatar?: string; /** - * Preferred terminology language (e.g. zh_CN). Written by FE language switch when logged in. + * Preferred terminology language. Empty uses the session default. */ - @Field({ - type: 'varchar', + @Field({ + type: 'ManyToOneRef', + relation: { targetModel: 'base.Language' }, + condition: ['IsActive', '=', true], size: 20, + index: true, string: _lt('Language', { scope: 'auth.model.User.fields' }), - help: _lt('When set, must match an active base.Language POSIX terminology code.', { + help: _lt('Active base.Language row used for terminology.', { scope: 'auth.model.User.fields', }), }) - Language: string | null; + LanguageId?: string | null; /** * Preferred IANA timezone for localization and display. @@ -356,32 +359,31 @@ export default class User extends AttachmentOwnerMixin { } /** - * User.Language is a POSIX terminology code that must reference an active base.Language row. + * LanguageId must reference an active base.Language row. */ - @Constraint(['Language']) + @Constraint(['LanguageId']) async validateLanguageConstraint(): Promise { - const raw = this.Language; - if (raw == null || !String(raw).trim()) { - this.Language = null; + const languageId = normalizeRefId(this.LanguageId); + if (!languageId) { + this.LanguageId = null; return; } - const code = String(raw).trim(); const active = await Language.Search( { And: [ - ['Code', '=', code], + ['Id', '=', languageId], ['IsActive', '=', true], ], }, - { fields: ['Code'], limit: 1 } + { fields: ['Id'], limit: 1 } ); if (!active?.length) { throw newAuthError({ code: AuthErrCode.VALIDATION_FAILED, - message: _t('Invalid or inactive language: %s', { scope: 'service/models/user' }, code), + message: _t('Invalid or inactive language: %s', { scope: 'service/models/user' }, languageId), }).withGrpcCode(GrpcCode.InvalidArgument); } - this.Language = code; + this.LanguageId = languageId; } /** @@ -535,8 +537,20 @@ export default class User extends AttachmentOwnerMixin { } } + let language: string | undefined; + const languageId = String(user?.LanguageId || '').trim(); + if (languageId) { + try { + const row = await Language.Browse(languageId, ['Code', 'IsActive']); + const code = String(row?.Code || '').trim(); + if (row && row.IsActive !== false && code) language = code; + } catch { + // Missing language row leaves terminology language unset. + } + } + return { - language: user.Language ?? undefined, + language, timezone: user.Timezone || undefined, companyTimezone, allowedCompanyIds: companyScope.allowedCompanyIds, diff --git a/modules/auth/service/tests/bootstrap_gift_pack.test.ts b/modules/auth/service/tests/bootstrap_gift_pack.test.ts index f85540f17..a8901da0a 100644 --- a/modules/auth/service/tests/bootstrap_gift_pack.test.ts +++ b/modules/auth/service/tests/bootstrap_gift_pack.test.ts @@ -402,7 +402,7 @@ test('PR-C-2 gift pack: base.user can read Company columns but not write them', // Profile writes are field-allowlisted; sensitive auth.User fields stay deny-write. // PR-D-2: PasswordHash also has an explicit field-scope deny overlay (deny-read + deny-write). - expect(((out.frUser as any)?.denyWriteFields || []).includes('Language')).toBe(false); + expect(((out.frUser as any)?.denyWriteFields || []).includes('LanguageId')).toBe(false); expect(((out.frUser as any)?.denyWriteFields || []).includes('Timezone')).toBe(false); expect(((out.frUser as any)?.denyReadFields || []).includes('PasswordHash')).toBe(true); expect(((out.frUser as any)?.denyWriteFields || []).includes('PasswordHash')).toBe(true); diff --git a/modules/auth/service/tests/register_company_scope.test.ts b/modules/auth/service/tests/register_company_scope.test.ts index 3b24640d7..8bdc39615 100644 --- a/modules/auth/service/tests/register_company_scope.test.ts +++ b/modules/auth/service/tests/register_company_scope.test.ts @@ -210,7 +210,7 @@ test('Register: strips server-managed fields from User payload', async () => { Id: 'usr_spoof_____________', PasswordHash: 'over-posted-hash', // Non-string allowlisted keys must also be dropped. - Language: { spoof: true }, + LanguageId: { spoof: true }, } as any, Password: 'password-123', }); @@ -233,7 +233,7 @@ test('Register: strips server-managed fields from User payload', async () => { const user = await withModelContext( { activeCompanyId: mainCompanyId, enabledCompanyIds: [mainCompanyId] } as any, async () => - User.Browse(userId, ['Id', 'Username', 'Email', 'CompanyId', 'CompanyIds', 'Preferences', 'PasswordHash', 'Language', 'FirstName', 'LastName'] as any), + User.Browse(userId, ['Id', 'Username', 'Email', 'CompanyId', 'CompanyIds', 'Preferences', 'PasswordHash', 'LanguageId', 'FirstName', 'LastName'] as any), { merge: false } ); expect((user as any).CompanyId).toBe(mainCompanyId); @@ -246,7 +246,7 @@ test('Register: strips server-managed fields from User payload', async () => { expect(companyIds.includes('cmp_spoof___________')).toBe(false); expect(String((user as any).PasswordHash || '')).not.toBe('over-posted-hash'); expect(String((user as any).PasswordHash || '') === '').toBe(false); - expect((user as any).Language).not.toEqual({ spoof: true }); + expect((user as any).LanguageId).not.toEqual({ spoof: true }); }); test('Register: ignores prototype-inherited allowlisted fields', async () => { diff --git a/modules/auth/service/tests/user_timezone.test.ts b/modules/auth/service/tests/user_timezone.test.ts index e2c312b27..1739ab40b 100644 --- a/modules/auth/service/tests/user_timezone.test.ts +++ b/modules/auth/service/tests/user_timezone.test.ts @@ -13,7 +13,7 @@ function hostWithTimezone(timezone: string | null | undefined): User { function metadataStub(overrides: Record): User { return Object.assign(Object.create(User.prototype), { Id: '', - Language: null, + LanguageId: null, Timezone: null, CompanyId: '', CompanyIds: [], @@ -62,9 +62,15 @@ test('auth.User validateTimezoneConstraint rejects invalid IANA ids', () => { }); test('auth.User extractUserMetadata omits null timezone', async () => { + const { createServiceByModel } = await import('@/core/service/rpc'); + const LanguageService = createServiceByModel('base.Language'); + const rows = await LanguageService.Search(['Code', '=', 'en_US'] as any, { fields: ['Id'], limit: 1 } as any); + const languageId = String((rows as any)?.[0]?.Id || ''); + expect(languageId).not.toBe(''); + const metadata = await User.extractUserMetadata( metadataStub({ - Language: 'en_US', + LanguageId: languageId, Timezone: null, }) ); @@ -182,7 +188,6 @@ test('auth.User extractUserMetadata includes timezone and tolerates missing comp const metadata = await User.extractUserMetadata( metadataStub({ Id: '', - Language: 'en_US', Timezone: 'America/New_York', CompanyId: 'missing-company-id', CompanyIds: ['missing-company-id'], @@ -229,7 +234,6 @@ test('auth.User extractUserMetadata reads companyTimezone from MAIN company', as const metadata = await User.extractUserMetadata( metadataStub({ Id: '', - Language: 'en_US', Timezone: 'UTC', CompanyId: main.Id, CompanyIds: [main.Id], diff --git a/modules/auth/web/components/preferences/OPreferencesDialog.vue b/modules/auth/web/components/preferences/OPreferencesDialog.vue index 2ad7cf5f5..961567771 100644 --- a/modules/auth/web/components/preferences/OPreferencesDialog.vue +++ b/modules/auth/web/components/preferences/OPreferencesDialog.vue @@ -83,6 +83,7 @@ const displayName = computed(() => { }); const languageCode = ref(''); +const savedLanguageCode = ref(''); const timezone = ref(null); const languageFromSession = ref(false); const timezoneFromBrowser = ref(false); @@ -121,8 +122,19 @@ async function loadTimezoneOptions() { timezoneOptions.value = []; } -function syncLanguageFromUser() { - const resolved = resolvePreferenceLanguage(currentUser.value?.Language, i18nStore.terminologyLang); +async function syncLanguageFromUser() { + const languageId = String(currentUser.value?.LanguageId || '').trim(); + let code = ''; + if (languageId) { + try { + const row = await (languageStore as any).Browse(languageId, ['Code']); + code = String(row?.Code || '').trim(); + } catch { + code = ''; + } + } + savedLanguageCode.value = code; + const resolved = resolvePreferenceLanguage(code || null, i18nStore.terminologyLang); languageCode.value = resolved.code; languageFromSession.value = resolved.fromSession; } @@ -162,7 +174,7 @@ watch( watch(languageCode, code => { if (!languageFromSession.value) return; - const saved = String(currentUser.value?.Language || '').trim(); + const saved = savedLanguageCode.value; if (code !== saved && code !== String(i18nStore.terminologyLang || '').trim()) { languageFromSession.value = false; } @@ -203,15 +215,27 @@ async function handleSave() { } const nextLang = String(languageCode.value || '').trim(); const nextTz = timezone.value ? String(timezone.value).trim() : null; + let languageId: string | null = null; + if (nextLang) { + const rows = (await (languageStore as any).Search( + { And: [['Code', '=', nextLang], ['IsActive', '=', true]] } as any, + { fields: ['Id'], limit: 1 } as any + )) as Array<{ Id?: string }>; + languageId = String(rows?.[0]?.Id || '').trim() || null; + if (!languageId) { + throw new Error(_t('Invalid or inactive language')); + } + } await userStore.UpdateById( userId, - { Language: nextLang || null, Timezone: nextTz } as any, - ['Id', 'Language', 'Timezone'] as any + { LanguageId: languageId, Timezone: nextTz } as any, + ['Id', 'LanguageId', 'Timezone'] as any ); if (authStore.currentUser) { - (authStore.currentUser as any).Language = nextLang || null; + (authStore.currentUser as any).LanguageId = languageId; (authStore.currentUser as any).Timezone = nextTz; } + savedLanguageCode.value = nextLang; if (nextLang) { await i18nStore.setUiKey(langToUiKey(nextLang)); } diff --git a/modules/auth/web/components/preferences/preferences_defaults.ts b/modules/auth/web/components/preferences/preferences_defaults.ts index 86da4204d..3c69795ea 100644 --- a/modules/auth/web/components/preferences/preferences_defaults.ts +++ b/modules/auth/web/components/preferences/preferences_defaults.ts @@ -16,7 +16,7 @@ export function detectBrowserTimezone(): string | null { } /** - * Resolve the Language select value: saved User.Language, else current session + * Resolve the Language select value: saved terminology code, else current session * terminology lang, else en_US. Marks whether the value came from the session * (not a persisted user preference). */ diff --git a/modules/auth/web/pages/Login.vue b/modules/auth/web/pages/Login.vue index f4863343c..2c0e289a0 100644 --- a/modules/auth/web/pages/Login.vue +++ b/modules/auth/web/pages/Login.vue @@ -130,13 +130,19 @@ async function handleLogin() { await authStore.login(form.username, form.password, '', '', form.rememberMe); - // Apply persisted User.Language when present (terminology lang → UI locale). + // Apply persisted User.LanguageId when present (terminology lang → UI locale). try { await authStore.loadUser(true); - const preferredLang = String((authStore.currentUser as any)?.Language || '').trim(); + const languageId = String((authStore.currentUser as any)?.LanguageId || '').trim(); const i18nStore = useI18nStore(); - if (preferredLang) { - await i18nStore.setUiKey(langToUiKey(preferredLang)); + if (languageId) { + const { createStoreByModel } = await import('@/web/web/stores/registry'); + const languageStore = createStoreByModel('base.Language'); + const row = await (languageStore as any).Browse(languageId, ['Code']); + const preferredLang = String(row?.Code || '').trim(); + if (preferredLang) { + await i18nStore.setUiKey(langToUiKey(preferredLang)); + } } i18nStore.setDisplayOverrides((authStore.currentUser as any)?.Preferences?.display ?? null); } catch { diff --git a/modules/auth/web/stores/auth/actions.ts b/modules/auth/web/stores/auth/actions.ts index 93b9265f4..70abb6bae 100644 --- a/modules/auth/web/stores/auth/actions.ts +++ b/modules/auth/web/stores/auth/actions.ts @@ -392,13 +392,13 @@ export function defineAuthActions(state: AuthState, helpers: AuthHelpers, deps?: if (!forceRefresh && state.currentUser.value) return true; // Fetch the current user profile from the backend store. - const user = await state.userStore.Browse(userId, ['Id', 'Username', 'Email', 'Language', 'Timezone', 'Preferences']); + const user = await state.userStore.Browse(userId, ['Id', 'Username', 'Email', 'LanguageId', 'Timezone', 'Preferences']); state.currentUser.value = user; - // Align FE UI key with User.Language (covers initAuth refresh paths; Login also applies this). + // Align FE UI key with the user's LanguageId (covers initAuth refresh paths; Login also applies this). try { const { useI18nStore, langToUiKey } = await import('@/web/web/stores/i18nStore'); const i18nStore = useI18nStore(); - const preferredLang = String((user as any)?.Language || '').trim(); + const preferredLang = await terminologyCodeFromLanguageId((user as any)?.LanguageId); if (preferredLang) { await i18nStore.setUiKey(langToUiKey(preferredLang)); } @@ -496,7 +496,7 @@ export function defineAuthActions(state: AuthState, helpers: AuthHelpers, deps?: } /** - * Persist terminology language preference for the logged-in user (User.Language). + * Persist terminology language preference for the logged-in user (User.LanguageId). * Anonymous callers no-op; FE still keeps locale in i18nStore localStorage. */ async function persistLanguagePreference(lang: string): Promise { @@ -511,14 +511,31 @@ export function defineAuthActions(state: AuthState, helpers: AuthHelpers, deps?: if (!userId) { return; } - await state.userStore.UpdateById(userId, { Language: terminologyLang } as any, ['Id', 'Language'] as any); + const { createStoreByModel } = await import('@/web/web/stores/registry'); + const languageStore = createStoreByModel('base.Language'); + const rows = (await (languageStore as any).Search( + { And: [['Code', '=', terminologyLang], ['IsActive', '=', true]] } as any, + { fields: ['Id'], limit: 1 } as any + )) as Array<{ Id?: string }>; + const languageId = String(rows?.[0]?.Id || '').trim(); + if (!languageId) return; + await state.userStore.UpdateById(userId, { LanguageId: languageId } as any, ['Id', 'LanguageId'] as any); if (state.currentUser.value) { - (state.currentUser.value as any).Language = terminologyLang; + (state.currentUser.value as any).LanguageId = languageId; } // Sync JWT metadata.language after preference write. await refreshTokenImpl(true); } + async function terminologyCodeFromLanguageId(languageId: unknown): Promise { + const id = String(languageId || '').trim(); + if (!id) return ''; + const { createStoreByModel } = await import('@/web/web/stores/registry'); + const languageStore = createStoreByModel('base.Language'); + const row = await (languageStore as any).Browse(id, ['Code']); + return String(row?.Code || '').trim(); + } + // Wrap public async actions with shared loading-state bookkeeping. const register = withLoading(registerImpl, state.loading); const login = withLoading(loginImpl, state.loading); diff --git a/modules/auth/web/views/UserFormView.vue b/modules/auth/web/views/UserFormView.vue index 0c87032d0..f6d3de5ce 100644 --- a/modules/auth/web/views/UserFormView.vue +++ b/modules/auth/web/views/UserFormView.vue @@ -40,7 +40,7 @@ SPDX-License-Identifier: Apache-2.0 - + +// SPDX-License-Identifier: Apache-2.0 + +import { Decimal } from '@/core/service'; +import { ChoysumError, GrpcCode } from '@/core/service/error'; +import { createTranslate } from '@/core/service/i18n'; +import type { FieldSelection, Projected } from '@/core/service/api/selection'; +import { parseDecimalInput, resolveModelRefId } from '@/core/service/utils/normalization'; +import { mapNormalizationToBase, assertRefId } from './_normalizers'; +import type UoM from './uom'; +import type { UoMConvertParams, UoMConvertResult } from './uom'; + +const { _t } = createTranslate('base'); + +const UOM_FIELDS = ['Id', 'CategoryId', 'Factor', 'Rounding'] as const; +type UoMRow = Projected; + +type UoMOps = { + Browse: (id: string, fields?: FieldSelection) => Promise; +}; + +function notFound(message: string): never { + throw new ChoysumError({ domain: 'base', code: 'NotFound', message }).withGrpcCode(GrpcCode.NotFound); +} + +function invalid(message: string): never { + throw new ChoysumError({ domain: 'base', code: 'InvalidArgument', message }).withGrpcCode(GrpcCode.InvalidArgument); +} + +/** + * Round `amount` to the UoM rounding step (half up). No-op when rounding is unset. + */ +function roundToUoM(amount: Decimal, rounding: unknown): Decimal { + if (rounding == null || rounding === '') return amount; + const step = rounding instanceof Decimal ? rounding : new Decimal(String(rounding)); + if (!step.isFinite() || step.lte(0)) return amount; + return amount.div(step).toDecimalPlaces(0, Decimal.ROUND_HALF_UP).mul(step); +} + +/** + * Convert an amount between two UoMs in the same category. + * + * Factor is the multiplier from this unit to the category reference unit: + * `amount_to = amount * from.Factor / to.Factor`, then rounded to the target UoM rounding. + */ +export async function convertUoM(UoMModel: UoMOps, params: UoMConvertParams | undefined | null): Promise { + const fromUoMId = assertRefId(params?.FromUoMId, 'FromUoMId'); + const toUoMId = assertRefId(params?.ToUoMId, 'ToUoMId'); + const amount = mapNormalizationToBase( + () => parseDecimalInput(params?.Amount, { allowNumber: false }), + () => _t('Invalid Amount', { scope: 'service/models/_uom_convert' }) + ); + + const load = async (id: string, label: string): Promise => { + let row: UoM | null | undefined; + try { + row = await UoMModel.Browse(id, UOM_FIELDS); + } catch { + notFound(_t('%s not found', { scope: 'service/models/_uom_convert' }, label)); + } + if (!row) notFound(_t('%s not found', { scope: 'service/models/_uom_convert' }, label)); + return row as UoMRow; + }; + + const from = await load(fromUoMId, 'FromUoMId'); + const to = await load(toUoMId, 'ToUoMId'); + const fromCategory = String(resolveModelRefId(from, 'CategoryId') ?? '').trim(); + const toCategory = String(resolveModelRefId(to, 'CategoryId') ?? '').trim(); + if (!fromCategory || !toCategory || fromCategory !== toCategory) { + invalid(_t('UoMs must belong to the same category', { scope: 'service/models/_uom_convert' })); + } + + const fromFactor = from.Factor instanceof Decimal ? from.Factor : new Decimal(String(from.Factor)); + const toFactor = to.Factor instanceof Decimal ? to.Factor : new Decimal(String(to.Factor)); + if (!fromFactor.isFinite() || fromFactor.lte(0) || !toFactor.isFinite() || toFactor.lte(0)) { + invalid(_t('UoM factor must be greater than 0', { scope: 'service/models/_uom_convert' })); + } + + const converted = amount.mul(fromFactor).div(toFactor); + return { Amount: roundToUoM(converted, to.Rounding) }; +} diff --git a/modules/base/service/models/uom.ts b/modules/base/service/models/uom.ts index ab9ff40dc..5cadefac1 100644 --- a/modules/base/service/models/uom.ts +++ b/modules/base/service/models/uom.ts @@ -8,6 +8,17 @@ import { toPositiveDecimal } from '@/core/service/utils/normalization'; import { _t, _lt } from '../i18n'; import UoMCategory from './uom_category'; import { fail, mapNormalizationToBase, assertRequiredTranslatedText, assertRefId } from './_normalizers'; +import { convertUoM } from './_uom_convert'; + +export type UoMConvertParams = { + Amount: Decimal | string; + FromUoMId: string; + ToUoMId: string; +}; + +export type UoMConvertResult = { + Amount: Decimal; +}; @Model('UoM') export default class UoM extends BaseModel { @@ -160,4 +171,9 @@ export default class UoM extends BaseModel { const currentId = String(this.Id || '').trim() || undefined; await UoM.validateEntity(this, currentId); } + + /** Convert an amount between two units in the same category. */ + static async Convert(params: UoMConvertParams): Promise { + return convertUoM(this as unknown as Parameters[0], params); + } } diff --git a/modules/base/service/tests/uom_convert.test.ts b/modules/base/service/tests/uom_convert.test.ts new file mode 100644 index 000000000..f086f25b7 --- /dev/null +++ b/modules/base/service/tests/uom_convert.test.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { Decimal } from '@/core/service'; +import { ChoysumError } from '@/core/service/error'; +import UoM from '@/base/service/models/uom'; +import UoMCategory from '@/base/service/models/uom_category'; +import { uid } from './_helpers'; + +async function createCategory(): Promise { + const category = await UoMCategory.Create( + { + Name: uid('UomCategory'), + Code: uid('UCODE').replace(/[^A-Za-z0-9]/g, '').slice(-12) || 'UCODE', + IsActive: true, + } as any, + ['Id'] as any + ); + return String((category as any).Id); +} + +async function createUnit(categoryId: string, factor: string, isReference: boolean, rounding?: string): Promise { + const row = await UoM.Create( + { + Name: uid(isReference ? 'Ref' : 'Uom'), + CategoryId: categoryId, + IsReference: isReference, + Factor: factor, + Rounding: rounding ?? null, + IsActive: true, + } as any, + ['Id'] as any + ); + return String((row as any).Id); +} + +test('base.UoM Convert scales by factor within one category and rounds to the target', async () => { + const categoryId = await createCategory(); + const gramId = await createUnit(categoryId, '1', true); + const kilogramId = await createUnit(categoryId, '1000', false, '0.01'); + + const result = await UoM.Convert({ + Amount: '2', + FromUoMId: kilogramId, + ToUoMId: gramId, + }); + expect(result.Amount.eq(new Decimal('2000'))).toBe(true); + + const back = await UoM.Convert({ + Amount: '15', + FromUoMId: gramId, + ToUoMId: kilogramId, + }); + expect(back.Amount.eq(new Decimal('0.02'))).toBe(true); +}); + +test('base.UoM Convert rejects units from different categories', async () => { + const weightId = await createCategory(); + const lengthId = await createCategory(); + const gramId = await createUnit(weightId, '1', true); + const meterId = await createUnit(lengthId, '1', true); + + let error: unknown; + try { + await UoM.Convert({ Amount: '1', FromUoMId: gramId, ToUoMId: meterId }); + } catch (err) { + error = err; + } + expect(error instanceof ChoysumError).toBe(true); + expect((error as ChoysumError).domain).toBe('base'); + expect((error as ChoysumError).code).toBe('InvalidArgument'); +}); diff --git a/modules/base/web/views/language_merge.mapping.test.ts b/modules/base/web/views/language_merge.mapping.test.ts index 0c2a590dd..a27e159a4 100644 --- a/modules/base/web/views/language_merge.mapping.test.ts +++ b/modules/base/web/views/language_merge.mapping.test.ts @@ -45,10 +45,11 @@ test('bootstrap.json has POSIX languages and no Locale entity', () => { expect(byId.company_main?.values).not.toHaveProperty('LocaleId'); }); -test('auth smoke and company child fixtures use Language without LocaleId', () => { +test('auth smoke and company child fixtures use LanguageId without LocaleId', () => { const smokeRaw = JSON.stringify(smoke); expect(smokeRaw).not.toMatch(/LocaleId|language_zh[^_]|locale_default/); - expect(smokeRaw).toMatch(/"Language":\s*"zh_CN"/); + expect(smokeRaw).toMatch(/base\.language_zh_cn/); + expect(smokeRaw).not.toMatch(/"Language":\s*"zh_CN"/); const companyChildRaw = JSON.stringify(companyChild); expect(companyChildRaw).not.toMatch(/LocaleId|locale_default/); diff --git a/modules/core/service/i18n/request_lang.ts b/modules/core/service/i18n/request_lang.ts index c15983ab9..9b03f974f 100644 --- a/modules/core/service/i18n/request_lang.ts +++ b/modules/core/service/i18n/request_lang.ts @@ -5,7 +5,7 @@ import type { RequestContextKV } from '../../rpc/context'; import { getCurrentRequestContext } from '../../rpc/context'; export type ResolveRequestLangFallbacks = { - /** User.Language when baggage lang is absent. */ + /** Preferred terminology code when baggage lang is absent. */ userLanguage?: string; /** Company default language. */ companyDefault?: string; diff --git a/modules/partner/service/models/partner.ts b/modules/partner/service/models/partner.ts index f020107fa..8154ab4aa 100644 --- a/modules/partner/service/models/partner.ts +++ b/modules/partner/service/models/partner.ts @@ -2,7 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { Compute, Field, Model } from '@/core/service'; +import type { ModelCtor, RowOf } from '@/core/service'; import { Constraint } from '@/core/service/api/constraint'; +import { getActiveCompanyId } from '@/core/service/api/context'; +import type { FieldSelection, Insertable } from '@/core/service/api'; import { normalizeRefId } from '@/core/service/utils/normalization'; import PartnerCollaborationModel from '../mixins/partner_collaboration_model'; import { _t, _lt } from '../i18n'; @@ -13,6 +16,28 @@ import type Currency from '@/base/service/models/currency'; import type Language from '@/base/service/models/language'; import PartnerContact from './partner_contact'; +export type PartnerFindOrCreateReq = { + Code: string; + /** Display name used only when a new partner is created. Defaults to Code. */ + Name?: string; +}; + +export type PartnerFindOrCreateResp = { + PartnerId: string; + Created: boolean; +}; + +function requireSessionCompanyId(): string { + const companyId = String(getActiveCompanyId() || '').trim(); + if (!companyId) fail(_t('CompanyId is required', { scope: 'service/models/partner' })); + return companyId; +} + +function codeSeedFromName(name: string): string { + const seed = name.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 32); + return seed || 'P'; +} + /** * Company-scoped business partner master record with derived default contacts and addresses. * @@ -349,4 +374,62 @@ export default class Partner extends PartnerCollaborationModel { await Partner.validateEntity(this as unknown as Record, currentId); } + + /** Return the company partner with this code, creating it when missing. */ + static async FindOrCreate(req: PartnerFindOrCreateReq): Promise { + const companyId = requireSessionCompanyId(); + const code = assertRequiredText(req?.Code, 'Code').toUpperCase(); + const existing = await this.Search( + { And: [['CompanyId', '=', companyId], ['Code', '=', code]] }, + { fields: ['Id'], limit: 1 } + ); + const existingId = String(existing?.[0]?.Id || '').trim(); + if (existingId) return { PartnerId: existingId, Created: false }; + + const name = req?.Name != null && String(req.Name).trim() ? assertRequiredText(req.Name, 'Name') : code; + try { + const created = await this.Create({ Name: name, Code: code, CompanyId: companyId } as Partial, ['Id'] as any); + return { PartnerId: String((created as { Id?: unknown }).Id || ''), Created: true }; + } catch (err) { + const again = await this.Search( + { And: [['CompanyId', '=', companyId], ['Code', '=', code]] }, + { fields: ['Id'], limit: 1 } + ); + const racedId = String(again?.[0]?.Id || '').trim(); + if (racedId) return { PartnerId: racedId, Created: false }; + throw err; + } + } + + /** + * Create a partner from a display name (relation typeahead). + * Code is derived from the name and disambiguated within the session company. + */ + static async NameCreate> | undefined = undefined>( + this: C, + name: string, + values?: Partial>>, + options?: { returnFields?: F } + ): Promise { + const partnerValues = (values || {}) as Partial>; + const companyId = String(normalizeRefId(partnerValues.CompanyId) || requireSessionCompanyId()); + const display = assertRequiredText(name, 'Name'); + const seed = codeSeedFromName(display); + let code = String(partnerValues.Code || '').trim().toUpperCase() || seed.slice(0, 40); + if (!partnerValues.Code) { + for (let attempt = 0; attempt < 5; attempt++) { + const taken = await (this as unknown as typeof Partner).Search( + { And: [['CompanyId', '=', companyId], ['Code', '=', code]] }, + { fields: ['Id'], limit: 1 } + ); + if (!taken?.[0]?.Id) break; + const suffix = String(attempt + 1); + code = `${seed.slice(0, Math.max(1, 40 - suffix.length))}${suffix}`; + } + } + return (this as unknown as typeof Partner).Create( + { ...partnerValues, Name: display, Code: code, CompanyId: companyId } as Partial>, + (options?.returnFields as string[] | undefined) as any + ); + } } diff --git a/modules/partner/service/tests/find_or_create.test.ts b/modules/partner/service/tests/find_or_create.test.ts new file mode 100644 index 000000000..19bbac3ca --- /dev/null +++ b/modules/partner/service/tests/find_or_create.test.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { createServiceByModel } from '@/core/service/rpc'; +import { withContext } from '@/core/service/api/context'; +import type CompanyModel from '@/base/service/models/company'; +import type CurrencyModel from '@/base/service/models/currency'; +import Partner from '@/partner/service/models/partner'; + +const Company = createServiceByModel('base.Company'); +const Currency = createServiceByModel('base.Currency'); + +let seq = 0; +function uid(prefix: string): string { + seq += 1; + const xid = (globalThis as any).$choysum?.xid?.New?.(); + const u = typeof xid === 'string' && xid.trim() ? xid.trim() : String(Date.now()); + return `${prefix}_${u}_${seq}`; +} + +async function ensureCompanyId(): Promise { + const currencyRows = await Currency.Search([] as any, { fields: ['Id'] as any, limit: 1 } as any); + const currencyId = currencyRows?.[0]?.Id ? String((currencyRows[0] as any).Id) : undefined; + const created = await Company.Create( + { + Name: uid('PartnerCo'), + Code: `${seq}${uid('PC')}`.replace(/[^a-zA-Z0-9]/g, '').slice(0, 16).toUpperCase(), + Timezone: 'UTC', + CurrencyId: currencyId, + IsActive: true, + } as any, + ['Id'] as any + ); + return String((created as any).Id); +} + +function withCompany(companyId: string, fn: () => Promise): Promise { + return Promise.resolve(withContext({ activeCompanyId: companyId, enabledCompanyIds: [companyId] } as any, fn)); +} + +test('partner.Partner FindOrCreate is keyed by Code within the session company', async () => { + const companyId = await ensureCompanyId(); + const code = uid('PCODE').replace(/[^a-zA-Z0-9]/g, '').slice(0, 20).toUpperCase() || 'PCODE'; + + const created = await withCompany(companyId, () => Partner.FindOrCreate({ Code: code.toLowerCase(), Name: 'Acme' })); + expect(created.Created).toBe(true); + expect(created.PartnerId).not.toBe(''); + + const again = await withCompany(companyId, () => Partner.FindOrCreate({ Code: code })); + expect(again.Created).toBe(false); + expect(again.PartnerId).toBe(created.PartnerId); +}); + +test('partner.Partner NameCreate derives a company-unique code', async () => { + const companyId = await ensureCompanyId(); + const first = await withCompany(companyId, () => Partner.NameCreate('Northwind', undefined, { returnFields: ['Id'] })); + const second = await withCompany(companyId, () => Partner.NameCreate('Northwind', undefined, { returnFields: ['Id'] })); + expect(String((first as any).Id || '')).not.toBe(''); + expect(String((second as any).Id || '')).not.toBe(''); + expect(String((second as any).Id)).not.toBe(String((first as any).Id)); +}); diff --git a/modules/partner_commercial/service/models/partner_identifier.ts b/modules/partner_commercial/service/models/partner_identifier.ts index fb897e52b..86570b7a1 100644 --- a/modules/partner_commercial/service/models/partner_identifier.ts +++ b/modules/partner_commercial/service/models/partner_identifier.ts @@ -3,12 +3,25 @@ import { BaseModel, Field, Model } from '@/core/service'; import { Constraint } from '@/core/service/api/constraint'; +import { getActiveCompanyId } from '@/core/service/api/context'; +import { normalizeRefId } from '@/core/service/utils/normalization'; import { _t, _lt } from '../i18n'; import { fail, normalizeOptionalRefId, normalizeOptionalText, normalizeOptionalTranslatedText, assertRequiredText, assertDateOrUndefined } from './_partner_commercial_bridge'; import type Company from '@/base/service/models/company'; import type Country from '@/base/service/models/country'; import type Partner from '@/partner/service/models/partner'; +export type PartnerIdentifierLookupReq = { + IdentifierType: string; + Value: string; +}; + +export type PartnerIdentifierLookupResp = { + Found: boolean; + PartnerId?: string; + PartnerIdentifierId?: string; +}; + /** * Company-scoped commercial identifier row attached to a partner. */ @@ -264,4 +277,34 @@ export default class PartnerIdentifier extends BaseModel { await PartnerIdentifier.validateEntity(this as unknown as Record, currentId); } + + /** + * Find a partner by commercial identifier within the session company. + * Type is matched lowercase and value uppercase, matching stored normalization. + */ + static async Lookup(req: PartnerIdentifierLookupReq): Promise { + const companyId = String(getActiveCompanyId() || '').trim(); + if (!companyId) fail(_t('CompanyId is required', { scope: 'service/models/partner_identifier' })); + const identifierType = assertRequiredText(req?.IdentifierType, 'IdentifierType', { lower: true }); + const value = assertRequiredText(req?.Value, 'Value', { upper: true }); + const rows = await this.Search( + { + And: [ + ['CompanyId', '=', companyId], + ['IdentifierType', '=', identifierType], + ['Value', '=', value], + ], + }, + { fields: ['Id', 'PartnerId'], limit: 1 } + ); + const row = rows?.[0]; + const identifierId = String(row?.Id || '').trim(); + if (!identifierId) return { Found: false }; + const partnerId = normalizeRefId(row?.PartnerId) || ''; + return { + Found: true, + PartnerIdentifierId: identifierId, + PartnerId: partnerId || undefined, + }; + } } diff --git a/modules/partner_commercial/service/tests/partner_identifier_lookup.test.ts b/modules/partner_commercial/service/tests/partner_identifier_lookup.test.ts new file mode 100644 index 000000000..45bc8bea5 --- /dev/null +++ b/modules/partner_commercial/service/tests/partner_identifier_lookup.test.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { createServiceByModel } from '@/core/service/rpc'; +import { withContext } from '@/core/service/api/context'; +import type CompanyModel from '@/base/service/models/company'; +import type CurrencyModel from '@/base/service/models/currency'; +import type PartnerModel from '@/partner/service/models/partner'; +import PartnerIdentifier from '@/partner_commercial/service/models/partner_identifier'; + +const Company = createServiceByModel('base.Company'); +const Currency = createServiceByModel('base.Currency'); +const Partner = createServiceByModel('partner.Partner'); + +let seq = 0; +function uid(prefix: string): string { + seq += 1; + const xid = (globalThis as any).$choysum?.xid?.New?.(); + const u = typeof xid === 'string' && xid.trim() ? xid.trim() : String(Date.now()); + return `${prefix}_${u}_${seq}`; +} + +async function ensureCompanyId(): Promise { + const currencyRows = await Currency.Search([] as any, { fields: ['Id'] as any, limit: 1 } as any); + const currencyId = currencyRows?.[0]?.Id ? String((currencyRows[0] as any).Id) : undefined; + const created = await Company.Create( + { + Name: uid('IdCo'), + Code: `${seq}${uid('IC')}`.replace(/[^a-zA-Z0-9]/g, '').slice(0, 16).toUpperCase(), + Timezone: 'UTC', + CurrencyId: currencyId, + IsActive: true, + } as any, + ['Id'] as any + ); + return String((created as any).Id); +} + +function withCompany(companyId: string, fn: () => Promise): Promise { + return Promise.resolve(withContext({ activeCompanyId: companyId, enabledCompanyIds: [companyId] } as any, fn)); +} + +test('partner.PartnerIdentifier Lookup matches normalized type and value', async () => { + const companyId = await ensureCompanyId(); + const found = await withCompany(companyId, async () => { + const partner = await Partner.NameCreate(uid('Lookup'), undefined, { returnFields: ['Id'] }); + await PartnerIdentifier.Create( + { + PartnerId: String((partner as any).Id), + CompanyId: companyId, + IdentifierType: 'VAT', + Value: 'ab-12', + IsActive: true, + } as any, + ['Id'] as any + ); + return PartnerIdentifier.Lookup({ IdentifierType: ' Vat ', Value: ' ab-12 ' }); + }); + + expect(found.Found).toBe(true); + expect(found.PartnerIdentifierId).toBeTruthy(); + expect(found.PartnerId).toBeTruthy(); + + const missing = await withCompany(companyId, () => PartnerIdentifier.Lookup({ IdentifierType: 'vat', Value: 'nope' })); + expect(missing.Found).toBe(false); +}); diff --git a/modules/web/web/components/layout/OHeader.vue b/modules/web/web/components/layout/OHeader.vue index 27ef1ddae..f938ff685 100644 --- a/modules/web/web/components/layout/OHeader.vue +++ b/modules/web/web/components/layout/OHeader.vue @@ -297,7 +297,7 @@ async function handleLanguageChange(locale: string) { if (!ok) { return; } - // Guest path only: session UI key stays in i18nStore localStorage. Do not write User.Language here. + // Guest path only: session UI key stays in i18nStore localStorage. Do not write User.LanguageId here. const { afterLocaleChange, softLocaleRemount } = await import('@/web/web/stores/i18nStore/locale_remount'); await afterLocaleChange({ remount: softLocaleRemount }); } From 5bcfe6547b0bb5b305bd7e49ffad66c8a8b9da10 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Mon, 21 Sep 2026 17:08:23 +0800 Subject: [PATCH 2/8] fix(service-api): address W5 review findings and CI gates Harden LanguageId backfill, NameCreate conflict retries, Lookup ambiguity, and UoM Browse error propagation; sync zh_CN catalogs and add FE/BE coverage for LanguageId preference paths. --- modules/auth/i18n/auth.pot | 371 ++++++++++-------- modules/auth/i18n/zh_CN.po | 361 ++++++++++------- .../auth/service/hook/post_upgrade.test.ts | 77 ++++ modules/auth/service/hook/post_upgrade.ts | 10 +- .../preferences/OPreferencesDialog.vue | 15 +- .../preferences/preferences_language.test.ts | 26 ++ .../preferences/preferences_language.ts | 20 + modules/auth/web/pages/Login.vue | 22 +- modules/auth/web/stores/auth/actions.test.ts | 97 ++++- modules/auth/web/stores/auth/actions.ts | 45 ++- .../stores/auth/language_preference.test.ts | 56 +++ .../web/stores/auth/language_preference.ts | 35 ++ modules/base/i18n/base.pot | 272 +++++++------ modules/base/i18n/zh_CN.po | 266 +++++++------ modules/base/service/models/_uom_convert.ts | 7 +- .../base/service/tests/uom_convert.test.ts | 13 + modules/document/i18n/document.pot | 202 +++++----- modules/document/i18n/zh_CN.po | 205 +++++----- modules/partner/i18n/partner.pot | 98 +++-- modules/partner/i18n/zh_CN.po | 190 ++++----- modules/partner/service/models/partner.ts | 48 ++- .../service/tests/find_or_create.test.ts | 12 + .../i18n/partner_commercial.pot | 58 +-- modules/partner_commercial/i18n/zh_CN.po | 54 +-- .../service/models/partner_identifier.ts | 5 +- 25 files changed, 1573 insertions(+), 992 deletions(-) create mode 100644 modules/auth/service/hook/post_upgrade.test.ts create mode 100644 modules/auth/web/components/preferences/preferences_language.test.ts create mode 100644 modules/auth/web/components/preferences/preferences_language.ts create mode 100644 modules/auth/web/stores/auth/language_preference.test.ts create mode 100644 modules/auth/web/stores/auth/language_preference.ts diff --git a/modules/auth/i18n/auth.pot b/modules/auth/i18n/auth.pot index cd428f083..a46d1c64c 100644 --- a/modules/auth/i18n/auth.pot +++ b/modules/auth/i18n/auth.pot @@ -17,157 +17,157 @@ msgctxt "auth.model.CompanyScopedResource.fields" msgid "Name" msgstr "" -#: service/models/role.ts:117 +#: service/models/role.ts:122 msgctxt "auth.model.Role.fields" msgid "Accessible UI Resources" msgstr "" -#: service/models/role.ts:93 +#: service/models/role.ts:98 msgctxt "auth.model.Role.fields" msgid "Active" msgstr "" -#: service/models/role.ts:104 +#: service/models/role.ts:109 msgctxt "auth.model.Role.fields" msgid "Built-in" msgstr "" -#: service/models/role.ts:67 +#: service/models/role.ts:72 msgctxt "auth.model.Role.fields" msgid "Code" msgstr "" -#: service/models/role.ts:82 +#: service/models/role.ts:87 msgctxt "auth.model.Role.fields" msgid "Description" msgstr "" -#: service/models/role.ts:37 +#: service/models/role.ts:42 msgctxt "auth.model.Role.fields" msgid "Display Name" msgstr "" -#: service/models/role.ts:201 +#: service/models/role.ts:206 msgctxt "auth.model.Role.fields" msgid "Field Rules" msgstr "" -#: service/models/role.ts:171 +#: service/models/role.ts:176 msgctxt "auth.model.Role.fields" msgid "Implied By Roles" msgstr "" -#: service/models/role.ts:152 +#: service/models/role.ts:157 msgctxt "auth.model.Role.fields" msgid "Included Roles" msgstr "" -#: service/models/role.ts:191 +#: service/models/role.ts:196 msgctxt "auth.model.Role.fields" msgid "Method Accesses" msgstr "" -#: service/models/role.ts:55 +#: service/models/role.ts:60 msgctxt "auth.model.Role.fields" msgid "Name" msgstr "" -#: service/models/role.ts:153 +#: service/models/role.ts:158 msgctxt "auth.model.Role.fields" msgid "Permissions from included roles are merged into this role." msgstr "" -#: service/models/role.ts:181 +#: service/models/role.ts:186 msgctxt "auth.model.Role.fields" msgid "Record Rules" msgstr "" -#: service/models/role.ts:68 +#: service/models/role.ts:73 msgctxt "auth.model.Role.fields" msgid "Stable programmatic id; also used as the display name." msgstr "" -#: service/models/role.ts:105 +#: service/models/role.ts:110 msgctxt "auth.model.Role.fields" msgid "System roles are seeded and should not be deleted casually." msgstr "" -#: service/models/role.ts:211 +#: service/models/role.ts:216 msgctxt "auth.model.Role.fields" msgid "UI Resources" msgstr "" -#: service/models/role.ts:118 +#: service/models/role.ts:123 msgctxt "auth.model.Role.fields" msgid "UI tree selection; drives menu and action visibility for this role." msgstr "" -#: service/models/role.ts:136 +#: service/models/role.ts:141 msgctxt "auth.model.Role.fields" msgid "Users" msgstr "" -#: service/models/role_field_rule.ts:117 -#: service/models/role_field_rule.ts:134 +#: service/models/role_field_rule.ts:118 +#: service/models/role_field_rule.ts:135 msgctxt "auth.model.RoleFieldRule.fields" msgid "Allow" msgstr "" -#: service/models/role_field_rule.ts:43 +#: service/models/role_field_rule.ts:44 msgctxt "auth.model.RoleFieldRule.fields" msgid "Application" msgstr "" -#: service/models/role_field_rule.ts:118 -#: service/models/role_field_rule.ts:135 +#: service/models/role_field_rule.ts:119 +#: service/models/role_field_rule.ts:136 msgctxt "auth.model.RoleFieldRule.fields" msgid "Deny" msgstr "" -#: service/models/role_field_rule.ts:85 +#: service/models/role_field_rule.ts:86 msgctxt "auth.model.RoleFieldRule.fields" msgid "Field" msgstr "" -#: service/models/role_field_rule.ts:44 -#: service/models/role_field_rule.ts:60 -#: service/models/role_field_rule.ts:86 +#: service/models/role_field_rule.ts:45 +#: service/models/role_field_rule.ts:61 +#: service/models/role_field_rule.ts:87 msgctxt "auth.model.RoleFieldRule.fields" msgid "Leave all scopes empty for global; otherwise pick one level (field scope also needs a model)." msgstr "" -#: service/models/role_field_rule.ts:103 +#: service/models/role_field_rule.ts:104 msgctxt "auth.model.RoleFieldRule.fields" msgid "Logical Model" msgstr "" -#: service/models/role_field_rule.ts:59 +#: service/models/role_field_rule.ts:60 msgctxt "auth.model.RoleFieldRule.fields" msgid "Model" msgstr "" -#: service/models/role_field_rule.ts:122 -#: service/models/role_field_rule.ts:139 +#: service/models/role_field_rule.ts:123 +#: service/models/role_field_rule.ts:140 msgctxt "auth.model.RoleFieldRule.fields" msgid "Override field visibility or editability; at least one of Read/Write must be set." msgstr "" -#: service/models/role_field_rule.ts:121 +#: service/models/role_field_rule.ts:122 msgctxt "auth.model.RoleFieldRule.fields" msgid "Read" msgstr "" -#: service/models/role_field_rule.ts:104 +#: service/models/role_field_rule.ts:105 msgctxt "auth.model.RoleFieldRule.fields" msgid "Registered short name covering all installed host applications that inject the same model. Applies to all business fields on that logical model. Mutually exclusive with Application / Model / Field." msgstr "" -#: service/models/role_field_rule.ts:30 +#: service/models/role_field_rule.ts:31 msgctxt "auth.model.RoleFieldRule.fields" msgid "Role" msgstr "" -#: service/models/role_field_rule.ts:138 +#: service/models/role_field_rule.ts:139 msgctxt "auth.model.RoleFieldRule.fields" msgid "Write" msgstr "" @@ -182,82 +182,82 @@ msgctxt "auth.model.RoleInheritance.fields" msgid "Parent Role" msgstr "" -#: service/models/role_method_access.ts:123 +#: service/models/role_method_access.ts:124 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Allow" msgstr "" -#: service/models/role_method_access.ts:128 +#: service/models/role_method_access.ts:129 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Any matching deny overrides allow across scopes; default is deny." msgstr "" -#: service/models/role_method_access.ts:43 +#: service/models/role_method_access.ts:44 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Application" msgstr "" -#: service/models/role_method_access.ts:124 +#: service/models/role_method_access.ts:125 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Deny" msgstr "" -#: service/models/role_method_access.ts:77 +#: service/models/role_method_access.ts:78 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Leave all scopes empty for a global rule; matching scopes are OR-ed and any deny wins." msgstr "" -#: service/models/role_method_access.ts:109 +#: service/models/role_method_access.ts:110 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Logical Methods" msgstr "" -#: service/models/role_method_access.ts:94 +#: service/models/role_method_access.ts:95 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Logical Model" msgstr "" -#: service/models/role_method_access.ts:144 +#: service/models/role_method_access.ts:145 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Manual" msgstr "" -#: service/models/role_method_access.ts:127 +#: service/models/role_method_access.ts:128 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Mode" msgstr "" -#: service/models/role_method_access.ts:56 +#: service/models/role_method_access.ts:57 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Model" msgstr "" -#: service/models/role_method_access.ts:110 +#: service/models/role_method_access.ts:111 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Optional method names for logical-model scope (JSON string array). Leave empty to allow or deny all methods on that logical model." msgstr "" -#: service/models/role_method_access.ts:95 +#: service/models/role_method_access.ts:96 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Registered short name covering all installed host applications that inject the same model (e.g. TranslationTerm). Mutually exclusive with Application / Model / Service." msgstr "" -#: service/models/role_method_access.ts:30 +#: service/models/role_method_access.ts:31 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Role" msgstr "" -#: service/models/role_method_access.ts:76 +#: service/models/role_method_access.ts:77 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Service" msgstr "" -#: service/models/role_method_access.ts:150 +#: service/models/role_method_access.ts:151 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Source" msgstr "" -#: service/models/role_method_access.ts:145 +#: service/models/role_method_access.ts:146 msgctxt "auth.model.RoleMethodAccess.fields" msgid "UI (legacy)" msgstr "" @@ -362,47 +362,47 @@ msgctxt "auth.model.RoleUiResource.fields" msgid "UI Resource" msgstr "" -#: service/models/session.ts:32 +#: service/models/session.ts:33 msgctxt "auth.model.Session.fields" msgid "Access Token ID" msgstr "" -#: service/models/session.ts:44 +#: service/models/session.ts:45 msgctxt "auth.model.Session.fields" msgid "Device Info" msgstr "" -#: service/models/session.ts:66 +#: service/models/session.ts:67 msgctxt "auth.model.Session.fields" msgid "Expires At" msgstr "" -#: service/models/session.ts:54 +#: service/models/session.ts:55 msgctxt "auth.model.Session.fields" msgid "IP Address" msgstr "" -#: service/models/session.ts:77 +#: service/models/session.ts:78 msgctxt "auth.model.Session.fields" msgid "Last Activity" msgstr "" -#: service/models/session.ts:100 +#: service/models/session.ts:101 msgctxt "auth.model.Session.fields" msgid "Metadata" msgstr "" -#: service/models/session.ts:90 +#: service/models/session.ts:91 msgctxt "auth.model.Session.fields" msgid "Status" msgstr "" -#: service/models/session.ts:33 +#: service/models/session.ts:34 msgctxt "auth.model.Session.fields" msgid "Token JTI identifier, not the bearer secret." msgstr "" -#: service/models/session.ts:20 +#: service/models/session.ts:21 msgctxt "auth.model.Session.fields" msgid "User" msgstr "" @@ -457,137 +457,137 @@ msgctxt "auth.model.Token.fields" msgid "User" msgstr "" -#: service/models/user/user.ts:232 +#: service/models/user/user.ts:274 msgctxt "auth.model.User.fields" msgid "Accessible Companies" msgstr "" -#: service/models/user/user.ts:246 +#: service/models/user/user.ts:288 msgctxt "auth.model.User.fields" msgid "Active" msgstr "" -#: service/models/user/user.ts:148 +#: service/models/user/user.ts:201 +msgctxt "auth.model.User.fields" +msgid "Active base.Language row used for terminology." +msgstr "" + +#: service/models/user/user.ts:187 msgctxt "auth.model.User.fields" msgid "Avatar" msgstr "" -#: service/models/user/user.ts:233 +#: service/models/user/user.ts:275 msgctxt "auth.model.User.fields" msgid "Companies the user may switch to; must stay within role assignments." msgstr "" -#: service/models/user/user.ts:221 +#: service/models/user/user.ts:263 msgctxt "auth.model.User.fields" msgid "Company" msgstr "" -#: service/models/user/user.ts:173 +#: service/models/user/user.ts:215 msgctxt "auth.model.User.fields" msgid "Display timezone; empty uses browser timezone on first login." msgstr "" -#: service/models/user/user.ts:76 +#: service/models/user/user.ts:115 msgctxt "auth.model.User.fields" msgid "Email" msgstr "" -#: service/models/user/user.ts:110 +#: service/models/user/user.ts:149 msgctxt "auth.model.User.fields" msgid "First Name" msgstr "" -#: service/models/user/user.ts:131 +#: service/models/user/user.ts:170 msgctxt "auth.model.User.fields" msgid "Full Name" msgstr "" -#: service/models/user/user.ts:158 +#: service/models/user/user.ts:200 msgctxt "auth.model.User.fields" msgid "Language" msgstr "" -#: service/models/user/user.ts:257 +#: service/models/user/user.ts:299 msgctxt "auth.model.User.fields" msgid "Last Login" msgstr "" -#: service/models/user/user.ts:121 +#: service/models/user/user.ts:160 msgctxt "auth.model.User.fields" msgid "Last Name" msgstr "" -#: service/models/user/user.ts:99 +#: service/models/user/user.ts:138 msgctxt "auth.model.User.fields" msgid "Password Hash" msgstr "" -#: service/models/user/user.ts:87 +#: service/models/user/user.ts:126 msgctxt "auth.model.User.fields" msgid "Phone" msgstr "" -#: service/models/user/user.ts:185 +#: service/models/user/user.ts:227 msgctxt "auth.model.User.fields" msgid "Preferences" msgstr "" -#: service/models/user/user.ts:295 +#: service/models/user/user.ts:337 msgctxt "auth.model.User.fields" msgid "Roles" msgstr "" -#: service/models/user/user.ts:268 +#: service/models/user/user.ts:310 msgctxt "auth.model.User.fields" msgid "Sessions" msgstr "" -#: service/models/user/user.ts:172 +#: service/models/user/user.ts:214 msgctxt "auth.model.User.fields" msgid "Time Zone" msgstr "" -#: service/models/user/user.ts:279 +#: service/models/user/user.ts:321 msgctxt "auth.model.User.fields" msgid "Tokens" msgstr "" -#: service/models/user/user.ts:65 +#: service/models/user/user.ts:104 msgctxt "auth.model.User.fields" msgid "Username" msgstr "" -#: service/models/user/user.ts:159 -msgctxt "auth.model.User.fields" -msgid "When set, must match an active base.Language POSIX terminology code." -msgstr "" - -#: service/models/user_role.ts:53 +#: service/models/user_role.ts:51 msgctxt "auth.model.UserRole.fields" msgid "Company" msgstr "" -#: service/models/user_role.ts:54 +#: service/models/user_role.ts:52 msgctxt "auth.model.UserRole.fields" msgid "Leave empty to grant the role in every company the user can access." msgstr "" -#: service/models/user_role.ts:40 +#: service/models/user_role.ts:38 msgctxt "auth.model.UserRole.fields" msgid "Role" msgstr "" -#: service/models/user_role.ts:29 +#: service/models/user_role.ts:27 msgctxt "auth.model.UserRole.fields" msgid "User" msgstr "" -#: service/models/session.ts:117 +#: service/models/session.ts:118 msgctxt "service/models/session" msgid "Failed to revoke session" msgstr "" -#: service/models/session.ts:146 +#: service/models/session.ts:147 msgctxt "service/models/session" msgid "Failed to revoke user sessions" msgstr "" @@ -637,155 +637,210 @@ msgctxt "service/models/token" msgid "userId is required" msgstr "" -#: service/models/user/user.ts:930 +#: service/models/user/user.ts:77 +msgctxt "service/models/user" +msgid "%s requires a payload" +msgstr "" + +#: service/models/user/user.ts:1054 msgctxt "service/models/user" msgid "Failed to compute field rule spec" msgstr "" -#: service/models/user/user.ts:313 +#: service/models/user/user.ts:355 msgctxt "service/models/user" msgid "Invalid IANA timezone: %s" msgstr "" -#: service/models/user/user.ts:911 +#: service/models/user/user.ts:1035 msgctxt "service/models/user" msgid "Invalid model full name; expected \".\"" msgstr "" -#: service/models/user/user.ts:342 +#: service/models/user/user.ts:383 msgctxt "service/models/user" msgid "Invalid or inactive language: %s" msgstr "" -#: service/models/user/user.ts:656 +#: service/models/user/user.ts:464 +msgctxt "service/models/user" +msgid "Login requires a boolean RememberMe" +msgstr "" + +#: service/models/user/user.ts:476 +msgctxt "service/models/user" +msgid "Login requires a string DeviceInfo" +msgstr "" + +#: service/models/user/user.ts:470 +msgctxt "service/models/user" +msgid "Login requires a string IpAddress" +msgstr "" + +#: service/models/user/user.ts:458 +msgctxt "service/models/user" +msgid "Login requires username and password" +msgstr "" + +#: service/models/user/user.ts:785 msgctxt "service/models/user" msgid "Logout failed" msgstr "" -#: service/models/user/user.ts:895 +#: service/models/user/user.ts:766 +msgctxt "service/models/user" +msgid "Logout requires a boolean AllDevices" +msgstr "" + +#: service/models/user/user.ts:773 +msgctxt "service/models/user" +msgid "Logout requires a string DeviceInfo" +msgstr "" + +#: service/models/user/user.ts:759 +msgctxt "service/models/user" +msgid "Logout requires a token string" +msgstr "" + +#: service/models/user/user.ts:1019 msgctxt "service/models/user" msgid "Missing identity information (userId)" msgstr "" -#: service/models/user/user.ts:903 +#: service/models/user/user.ts:1027 msgctxt "service/models/user" msgid "Missing model name (model)" msgstr "" -#: service/models/user/user.ts:628 +#: service/models/user/user.ts:572 +msgctxt "service/models/user" +msgid "RefreshTokens requires a refresh token" +msgstr "" + +#: service/models/user/user.ts:398 +msgctxt "service/models/user" +msgid "Register requires a User object" +msgstr "" + +#: service/models/user/user.ts:404 +msgctxt "service/models/user" +msgid "Register requires a password string" +msgstr "" + +#: service/models/user/user.ts:746 msgctxt "service/models/user" msgid "Switch company scope failed" msgstr "" -#: service/models/user/user.ts:645 +#: service/models/user/user.ts:603 msgctxt "service/models/user" -msgid "Token is required" +msgid "SwitchCompanyScope requires an ActiveCompanyId" msgstr "" -#: service/models/user/user.ts:476 +#: service/models/user/user.ts:584 msgctxt "service/models/user" msgid "Token refresh failed" msgstr "" -#: service/models/user/user.ts:495 +#: service/models/user/user.ts:612 msgctxt "service/models/user" msgid "User is not logged in" msgstr "" -#: service/models/user/user.ts:512 +#: service/models/user/user.ts:629 msgctxt "service/models/user" msgid "activeCompanyId cannot be empty" msgstr "" -#: service/models/user/user.ts:563 +#: service/models/user/user.ts:680 msgctxt "service/models/user" msgid "activeCompanyId is outside the allowed company scope" msgstr "" -#: service/models/user/user.ts:578 +#: service/models/user/user.ts:695 msgctxt "service/models/user" msgid "activeCompanyId must be included in enabledCompanyIds" msgstr "" -#: service/models/user/user.ts:547 +#: service/models/user/user.ts:664 msgctxt "service/models/user" msgid "enabledCompanyIds contains an unauthorized company" msgstr "" -#: service/models/user/user.ts:531 +#: service/models/user/user.ts:648 msgctxt "service/models/user" msgid "enabledCompanyIds must be a string[] or omitted" msgstr "" -#: service/models/user/_field_rule_eval.ts:126 +#: service/models/user/_field_rule_eval.ts:119 msgctxt "service/models/user/_field_rule_eval" msgid "Model does not exist" msgstr "" -#: service/models/user/_lifecycle_auth.ts:269 -#: service/models/user/_lifecycle_auth.ts:359 +#: service/models/user/_lifecycle_auth.ts:268 +#: service/models/user/_lifecycle_auth.ts:358 msgctxt "service/models/user/_lifecycle_auth" msgid "Account is disabled" msgstr "" -#: service/models/user/_lifecycle_auth.ts:152 +#: service/models/user/_lifecycle_auth.ts:145 msgctxt "service/models/user/_lifecycle_auth" msgid "Email is already registered" msgstr "" -#: service/models/user/_lifecycle_auth.ts:345 -#: service/models/user/_lifecycle_auth.ts:379 +#: service/models/user/_lifecycle_auth.ts:344 +#: service/models/user/_lifecycle_auth.ts:378 msgctxt "service/models/user/_lifecycle_auth" msgid "Invalid token payload: missing user id" msgstr "" -#: service/models/user/_lifecycle_auth.ts:190 +#: service/models/user/_lifecycle_auth.ts:187 msgctxt "service/models/user/_lifecycle_auth" msgid "Registration failed: Main Company (Code=MAIN) was not found" msgstr "" -#: service/models/user/_lifecycle_auth.ts:215 +#: service/models/user/_lifecycle_auth.ts:212 msgctxt "service/models/user/_lifecycle_auth" msgid "Registration failed: base.user role is not initialized; load auth bootstrap data first" msgstr "" -#: service/models/user/_lifecycle_auth.ts:353 +#: service/models/user/_lifecycle_auth.ts:352 msgctxt "service/models/user/_lifecycle_auth" msgid "User not found" msgstr "" -#: service/models/user/_lifecycle_auth.ts:253 -#: service/models/user/_lifecycle_auth.ts:260 +#: service/models/user/_lifecycle_auth.ts:252 +#: service/models/user/_lifecycle_auth.ts:259 msgctxt "service/models/user/_lifecycle_auth" msgid "User not found or password is incorrect" msgstr "" -#: service/models/user/_lifecycle_auth.ts:168 +#: service/models/user/_lifecycle_auth.ts:161 msgctxt "service/models/user/_lifecycle_auth" msgid "User registration failed: missing user id" msgstr "" -#: service/models/user/_lifecycle_auth.ts:113 +#: service/models/user/_lifecycle_auth.ts:106 msgctxt "service/models/user/_lifecycle_auth" msgid "Username and password are required" msgstr "" -#: service/models/user/_lifecycle_auth.ts:140 +#: service/models/user/_lifecycle_auth.ts:133 msgctxt "service/models/user/_lifecycle_auth" msgid "Username is already in use" msgstr "" -#: service/models/user/_lifecycle_auth.ts:133 +#: service/models/user/_lifecycle_auth.ts:126 msgctxt "service/models/user/_lifecycle_auth" msgid "Username is required" msgstr "" -#: service/models/user/user.ts:425 +#: service/models/user/user.ts:513 msgctxt "service/models/user@Login" msgid "Login failed: unable to create token pair" msgstr "" -#: service/models/user/user.ts:386 +#: service/models/user/user.ts:443 msgctxt "service/models/user@Register" msgid "User registration failed" msgstr "" @@ -835,13 +890,13 @@ msgctxt "web/components/layout/OSwitchCompany" msgid "Available Companies" msgstr "" -#: web/components/layout/OSwitchCompany.vue:282 +#: web/components/layout/OSwitchCompany.vue:289 msgctxt "web/components/layout/OSwitchCompany" msgid "Available companies must include the current company" msgstr "" -#: web/components/layout/OSwitchCompany.vue:247 -#: web/components/layout/OSwitchCompany.vue:252 +#: web/components/layout/OSwitchCompany.vue:254 +#: web/components/layout/OSwitchCompany.vue:259 msgctxt "web/components/layout/OSwitchCompany" msgid "Company" msgstr "" @@ -851,17 +906,17 @@ msgctxt "web/components/layout/OSwitchCompany" msgid "Current Company" msgstr "" -#: web/components/layout/OSwitchCompany.vue:287 +#: web/components/layout/OSwitchCompany.vue:294 msgctxt "web/components/layout/OSwitchCompany" msgid "No changes to apply" msgstr "" -#: web/components/layout/OSwitchCompany.vue:285 +#: web/components/layout/OSwitchCompany.vue:292 msgctxt "web/components/layout/OSwitchCompany" msgid "Only one company is available; nothing to apply" msgstr "" -#: web/components/layout/OSwitchCompany.vue:280 +#: web/components/layout/OSwitchCompany.vue:287 msgctxt "web/components/layout/OSwitchCompany" msgid "Select a current company" msgstr "" @@ -886,8 +941,8 @@ msgctxt "web/components/preferences/OPreferencesDialog" msgid "Cancel" msgstr "" -#: web/components/preferences/OPreferencesDialog.vue:144 -#: web/components/preferences/OPreferencesDialog.vue:153 +#: web/components/preferences/OPreferencesDialog.vue:156 +#: web/components/preferences/OPreferencesDialog.vue:165 msgctxt "web/components/preferences/OPreferencesDialog" msgid "Cannot update preferences: missing user id" msgstr "" @@ -897,17 +952,22 @@ msgctxt "web/components/preferences/OPreferencesDialog" msgid "Edit Profile" msgstr "" -#: web/components/preferences/OPreferencesDialog.vue:176 +#: web/components/preferences/OPreferencesDialog.vue:200 msgctxt "web/components/preferences/OPreferencesDialog" msgid "Failed to update preferences" msgstr "" +#: web/components/preferences/OPreferencesDialog.vue:178 +msgctxt "web/components/preferences/OPreferencesDialog" +msgid "Invalid or inactive language" +msgstr "" + #: web/components/preferences/OPreferencesDialog.vue:11 msgctxt "web/components/preferences/OPreferencesDialog" msgid "Language" msgstr "" -#: web/components/preferences/OPreferencesDialog.vue:173 +#: web/components/preferences/OPreferencesDialog.vue:197 msgctxt "web/components/preferences/OPreferencesDialog" msgid "Preferences updated" msgstr "" @@ -993,13 +1053,13 @@ msgid "Don't have an account?" msgstr "" #: web/pages/Login.vue:20 -#: web/pages/Login.vue:48 +#: web/pages/Login.vue:50 msgctxt "web/pages/Login" msgid "Enter password" msgstr "" #: web/pages/Login.vue:16 -#: web/pages/Login.vue:41 +#: web/pages/Login.vue:43 msgctxt "web/pages/Login" msgid "Enter username" msgstr "" @@ -1009,7 +1069,7 @@ msgctxt "web/pages/Login" msgid "Log In" msgstr "" -#: web/pages/Login.vue:116 +#: web/pages/Login.vue:114 msgctxt "web/pages/Login" msgid "Login failed. Please try again later." msgstr "" @@ -1445,77 +1505,82 @@ msgctxt "web/route/routes" msgid "User List" msgstr "" -#: web/stores/auth/actions.ts:344 +#: web/stores/auth/actions.ts:402 msgctxt "web/stores/auth/actions" msgid "Cannot load user details: missing userId" msgstr "" -#: web/stores/auth/actions.ts:424 +#: web/stores/auth/actions.ts:487 msgctxt "web/stores/auth/actions" msgid "Failed to initialize auth" msgstr "" -#: web/stores/auth/actions.ts:82 +#: web/stores/auth/actions.ts:108 msgctxt "web/stores/auth/actions" msgid "Failed to load permission state" msgstr "" -#: web/stores/auth/actions.ts:369 +#: web/stores/auth/actions.ts:432 msgctxt "web/stores/auth/actions" msgid "Failed to load user details" msgstr "" -#: web/stores/auth/actions.ts:324 +#: web/stores/auth/actions.ts:382 msgctxt "web/stores/auth/actions" msgid "Failed to refresh token" msgstr "" -#: web/stores/auth/actions.ts:128 +#: web/stores/auth/actions.ts:157 msgctxt "web/stores/auth/actions" msgid "Failed to switch company scope" msgstr "" -#: web/stores/auth/actions.ts:194 +#: web/stores/auth/actions.ts:246 msgctxt "web/stores/auth/actions" msgid "Invalid login response" msgstr "" -#: web/stores/auth/actions.ts:223 +#: web/stores/auth/actions.ts:275 msgctxt "web/stores/auth/actions" msgid "Login failed" msgstr "" -#: web/stores/auth/actions.ts:256 +#: web/stores/auth/actions.ts:312 msgctxt "web/stores/auth/actions" msgid "Logout failed, but local state was cleared" msgstr "" -#: web/stores/auth/actions.ts:280 +#: web/stores/auth/actions.ts:336 msgctxt "web/stores/auth/actions" msgid "No refresh token is available" msgstr "" -#: web/stores/auth/actions.ts:95 +#: web/stores/auth/actions.ts:121 msgctxt "web/stores/auth/actions" msgid "Not authenticated; cannot switch company scope" msgstr "" -#: web/stores/auth/actions.ts:292 +#: web/stores/auth/actions.ts:350 msgctxt "web/stores/auth/actions" msgid "RefreshTokens returned an invalid response" msgstr "" -#: web/stores/auth/actions.ts:154 +#: web/stores/auth/actions.ts:192 +msgctxt "web/stores/auth/actions" +msgid "Register returned an invalid response" +msgstr "" + +#: web/stores/auth/actions.ts:199 msgctxt "web/stores/auth/actions" msgid "Registration failed" msgstr "" -#: web/stores/auth/actions.ts:245 +#: web/stores/auth/actions.ts:301 msgctxt "web/stores/auth/actions" msgid "Server-side logout failed, but local state was cleared" msgstr "" -#: web/stores/auth/actions.ts:103 +#: web/stores/auth/actions.ts:132 msgctxt "web/stores/auth/actions" msgid "SwitchCompanyScope returned an invalid response" msgstr "" diff --git a/modules/auth/i18n/zh_CN.po b/modules/auth/i18n/zh_CN.po index 88188683d..dab8a0b4f 100644 --- a/modules/auth/i18n/zh_CN.po +++ b/modules/auth/i18n/zh_CN.po @@ -11,152 +11,152 @@ msgctxt "auth.model.CompanyScopedResource.fields" msgid "Name" msgstr "名称" -#: service/models/role.ts:117 +#: service/models/role.ts:122 msgctxt "auth.model.Role.fields" msgid "Accessible UI Resources" msgstr "可访问的界面资源" -#: service/models/role.ts:93 +#: service/models/role.ts:98 msgctxt "auth.model.Role.fields" msgid "Active" msgstr "启用" -#: service/models/role.ts:104 +#: service/models/role.ts:109 msgctxt "auth.model.Role.fields" msgid "Built-in" msgstr "内置" -#: service/models/role.ts:67 +#: service/models/role.ts:72 msgctxt "auth.model.Role.fields" msgid "Code" msgstr "编码" -#: service/models/role.ts:82 +#: service/models/role.ts:87 msgctxt "auth.model.Role.fields" msgid "Description" msgstr "描述" -#: service/models/role.ts:37 +#: service/models/role.ts:42 msgctxt "auth.model.Role.fields" msgid "Display Name" msgstr "显示名称" -#: service/models/role.ts:201 +#: service/models/role.ts:206 msgctxt "auth.model.Role.fields" msgid "Field Rules" msgstr "字段规则" -#: service/models/role.ts:171 +#: service/models/role.ts:176 msgctxt "auth.model.Role.fields" msgid "Implied By Roles" msgstr "被以下角色隐含" -#: service/models/role.ts:152 +#: service/models/role.ts:157 msgctxt "auth.model.Role.fields" msgid "Included Roles" msgstr "包含的角色" -#: service/models/role.ts:191 +#: service/models/role.ts:196 msgctxt "auth.model.Role.fields" msgid "Method Accesses" msgstr "方法访问" -#: service/models/role.ts:55 +#: service/models/role.ts:60 msgctxt "auth.model.Role.fields" msgid "Name" msgstr "名称" -#: service/models/role.ts:153 +#: service/models/role.ts:158 msgctxt "auth.model.Role.fields" msgid "Permissions from included roles are merged into this role." msgstr "所含角色的权限会合并进本角色。" -#: service/models/role.ts:181 +#: service/models/role.ts:186 msgctxt "auth.model.Role.fields" msgid "Record Rules" msgstr "记录规则" -#: service/models/role.ts:68 +#: service/models/role.ts:73 msgctxt "auth.model.Role.fields" msgid "Stable programmatic id; also used as the display name." msgstr "稳定的程序化标识;也用作显示名。" -#: service/models/role.ts:105 +#: service/models/role.ts:110 msgctxt "auth.model.Role.fields" msgid "System roles are seeded and should not be deleted casually." msgstr "系统角色由种子数据生成,请勿随意删除。" -#: service/models/role.ts:211 +#: service/models/role.ts:216 msgctxt "auth.model.Role.fields" msgid "UI Resources" msgstr "界面资源" -#: service/models/role.ts:118 +#: service/models/role.ts:123 msgctxt "auth.model.Role.fields" msgid "UI tree selection; drives menu and action visibility for this role." msgstr "UI 树选择;决定该角色的菜单与动作可见性。" -#: service/models/role.ts:136 +#: service/models/role.ts:141 msgctxt "auth.model.Role.fields" msgid "Users" msgstr "用户" -#: service/models/role_field_rule.ts:117 service/models/role_field_rule.ts:134 +#: service/models/role_field_rule.ts:118 service/models/role_field_rule.ts:135 msgctxt "auth.model.RoleFieldRule.fields" msgid "Allow" msgstr "允许" -#: service/models/role_field_rule.ts:43 +#: service/models/role_field_rule.ts:44 msgctxt "auth.model.RoleFieldRule.fields" msgid "Application" msgstr "应用" -#: service/models/role_field_rule.ts:118 service/models/role_field_rule.ts:135 +#: service/models/role_field_rule.ts:119 service/models/role_field_rule.ts:136 msgctxt "auth.model.RoleFieldRule.fields" msgid "Deny" msgstr "拒绝" -#: service/models/role_field_rule.ts:85 +#: service/models/role_field_rule.ts:86 msgctxt "auth.model.RoleFieldRule.fields" msgid "Field" msgstr "字段" -#: service/models/role_field_rule.ts:44 service/models/role_field_rule.ts:60 service/models/role_field_rule.ts:86 +#: service/models/role_field_rule.ts:45 service/models/role_field_rule.ts:61 service/models/role_field_rule.ts:87 msgctxt "auth.model.RoleFieldRule.fields" msgid "Leave all scopes empty for global; otherwise pick one level (field scope also needs a model)." msgstr "全部留空表示全局作用域;否则只选一级(字段级还需指定模型)。" -#: service/models/role_field_rule.ts:103 +#: service/models/role_field_rule.ts:104 msgctxt "auth.model.RoleFieldRule.fields" msgid "Logical Model" msgstr "逻辑模型" -#: service/models/role_field_rule.ts:59 +#: service/models/role_field_rule.ts:60 msgctxt "auth.model.RoleFieldRule.fields" msgid "Model" msgstr "模型" -#: service/models/role_field_rule.ts:122 service/models/role_field_rule.ts:139 +#: service/models/role_field_rule.ts:123 service/models/role_field_rule.ts:140 msgctxt "auth.model.RoleFieldRule.fields" msgid "Override field visibility or editability; at least one of Read/Write must be set." msgstr "覆盖字段可见性或可编辑性;读/写至少设置一项。" -#: service/models/role_field_rule.ts:121 +#: service/models/role_field_rule.ts:122 msgctxt "auth.model.RoleFieldRule.fields" msgid "Read" msgstr "读" -#: service/models/role_field_rule.ts:104 +#: service/models/role_field_rule.ts:105 msgctxt "auth.model.RoleFieldRule.fields" msgid "Registered short name covering all installed host applications that inject the same model. Applies to all business fields on that logical model. Mutually exclusive with Application / Model / Field." msgstr "已注册的短名,覆盖所有注入同一模型的已安装宿主应用;作用于该逻辑模型上的全部业务字段。与应用 / 模型 / 字段互斥。" -#: service/models/role_field_rule.ts:30 +#: service/models/role_field_rule.ts:31 msgctxt "auth.model.RoleFieldRule.fields" msgid "Role" msgstr "角色" -#: service/models/role_field_rule.ts:138 +#: service/models/role_field_rule.ts:139 msgctxt "auth.model.RoleFieldRule.fields" msgid "Write" msgstr "写" @@ -171,82 +171,82 @@ msgctxt "auth.model.RoleInheritance.fields" msgid "Parent Role" msgstr "父角色" -#: service/models/role_method_access.ts:123 +#: service/models/role_method_access.ts:124 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Allow" msgstr "允许" -#: service/models/role_method_access.ts:128 +#: service/models/role_method_access.ts:129 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Any matching deny overrides allow across scopes; default is deny." msgstr "任一匹配的拒绝都会覆盖允许(跨作用域);未设置时默认为拒绝。" -#: service/models/role_method_access.ts:43 +#: service/models/role_method_access.ts:44 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Application" msgstr "应用" -#: service/models/role_method_access.ts:124 +#: service/models/role_method_access.ts:125 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Deny" msgstr "拒绝" -#: service/models/role_method_access.ts:77 +#: service/models/role_method_access.ts:78 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Leave all scopes empty for a global rule; matching scopes are OR-ed and any deny wins." msgstr "全部留空为全局规则;匹配的作用域按或运算,任一拒绝优先。" -#: service/models/role_method_access.ts:109 +#: service/models/role_method_access.ts:110 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Logical Methods" msgstr "逻辑方法" -#: service/models/role_method_access.ts:94 +#: service/models/role_method_access.ts:95 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Logical Model" msgstr "逻辑模型" -#: service/models/role_method_access.ts:144 +#: service/models/role_method_access.ts:145 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Manual" msgstr "手动" -#: service/models/role_method_access.ts:127 +#: service/models/role_method_access.ts:128 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Mode" msgstr "模式" -#: service/models/role_method_access.ts:56 +#: service/models/role_method_access.ts:57 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Model" msgstr "模型" -#: service/models/role_method_access.ts:110 +#: service/models/role_method_access.ts:111 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Optional method names for logical-model scope (JSON string array). Leave empty to allow or deny all methods on that logical model." msgstr "逻辑模型作用域下的可选方法名(JSON 字符串数组)。留空表示对该逻辑模型的全部方法允许或拒绝。" -#: service/models/role_method_access.ts:95 +#: service/models/role_method_access.ts:96 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Registered short name covering all installed host applications that inject the same model (e.g. TranslationTerm). Mutually exclusive with Application / Model / Service." msgstr "已注册的短名,覆盖所有注入同一模型的已安装宿主应用(例如 TranslationTerm)。与应用 / 模型 / 服务互斥。" -#: service/models/role_method_access.ts:30 +#: service/models/role_method_access.ts:31 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Role" msgstr "角色" -#: service/models/role_method_access.ts:76 +#: service/models/role_method_access.ts:77 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Service" msgstr "服务" -#: service/models/role_method_access.ts:150 +#: service/models/role_method_access.ts:151 msgctxt "auth.model.RoleMethodAccess.fields" msgid "Source" msgstr "来源" -#: service/models/role_method_access.ts:145 +#: service/models/role_method_access.ts:146 msgctxt "auth.model.RoleMethodAccess.fields" msgid "UI (legacy)" msgstr "界面(遗留)" @@ -351,47 +351,47 @@ msgctxt "auth.model.RoleUiResource.fields" msgid "UI Resource" msgstr "界面资源" -#: service/models/session.ts:32 +#: service/models/session.ts:33 msgctxt "auth.model.Session.fields" msgid "Access Token ID" msgstr "访问令牌 ID" -#: service/models/session.ts:44 +#: service/models/session.ts:45 msgctxt "auth.model.Session.fields" msgid "Device Info" msgstr "设备信息" -#: service/models/session.ts:66 +#: service/models/session.ts:67 msgctxt "auth.model.Session.fields" msgid "Expires At" msgstr "过期时间" -#: service/models/session.ts:54 +#: service/models/session.ts:55 msgctxt "auth.model.Session.fields" msgid "IP Address" msgstr "IP 地址" -#: service/models/session.ts:77 +#: service/models/session.ts:78 msgctxt "auth.model.Session.fields" msgid "Last Activity" msgstr "最近活动" -#: service/models/session.ts:100 +#: service/models/session.ts:101 msgctxt "auth.model.Session.fields" msgid "Metadata" msgstr "元数据" -#: service/models/session.ts:90 +#: service/models/session.ts:91 msgctxt "auth.model.Session.fields" msgid "Status" msgstr "状态" -#: service/models/session.ts:33 +#: service/models/session.ts:34 msgctxt "auth.model.Session.fields" msgid "Token JTI identifier, not the bearer secret." msgstr "令牌 JTI 标识,不是 bearer 密钥本身。" -#: service/models/session.ts:20 +#: service/models/session.ts:21 msgctxt "auth.model.Session.fields" msgid "User" msgstr "用户" @@ -446,137 +446,137 @@ msgctxt "auth.model.Token.fields" msgid "User" msgstr "用户" -#: service/models/user/user.ts:232 +#: service/models/user/user.ts:274 msgctxt "auth.model.User.fields" msgid "Accessible Companies" msgstr "可访问公司" -#: service/models/user/user.ts:246 +#: service/models/user/user.ts:288 msgctxt "auth.model.User.fields" msgid "Active" msgstr "启用" -#: service/models/user/user.ts:148 +#: service/models/user/user.ts:201 +msgctxt "auth.model.User.fields" +msgid "Active base.Language row used for terminology." +msgstr "用于术语的有效 base.Language 记录。" + +#: service/models/user/user.ts:187 msgctxt "auth.model.User.fields" msgid "Avatar" msgstr "头像" -#: service/models/user/user.ts:233 +#: service/models/user/user.ts:275 msgctxt "auth.model.User.fields" msgid "Companies the user may switch to; must stay within role assignments." msgstr "用户可切换到的公司;须落在角色授权范围内。" -#: service/models/user/user.ts:221 +#: service/models/user/user.ts:263 msgctxt "auth.model.User.fields" msgid "Company" msgstr "公司" -#: service/models/user/user.ts:173 +#: service/models/user/user.ts:215 msgctxt "auth.model.User.fields" msgid "Display timezone; empty uses browser timezone on first login." msgstr "显示时区;留空则首次登录时使用浏览器时区。" -#: service/models/user/user.ts:76 +#: service/models/user/user.ts:115 msgctxt "auth.model.User.fields" msgid "Email" msgstr "邮箱" -#: service/models/user/user.ts:110 +#: service/models/user/user.ts:149 msgctxt "auth.model.User.fields" msgid "First Name" msgstr "名" -#: service/models/user/user.ts:131 +#: service/models/user/user.ts:170 msgctxt "auth.model.User.fields" msgid "Full Name" msgstr "全名" -#: service/models/user/user.ts:158 +#: service/models/user/user.ts:200 msgctxt "auth.model.User.fields" msgid "Language" msgstr "语言" -#: service/models/user/user.ts:257 +#: service/models/user/user.ts:299 msgctxt "auth.model.User.fields" msgid "Last Login" msgstr "最近登录" -#: service/models/user/user.ts:121 +#: service/models/user/user.ts:160 msgctxt "auth.model.User.fields" msgid "Last Name" msgstr "姓" -#: service/models/user/user.ts:99 +#: service/models/user/user.ts:138 msgctxt "auth.model.User.fields" msgid "Password Hash" msgstr "密码哈希" -#: service/models/user/user.ts:87 +#: service/models/user/user.ts:126 msgctxt "auth.model.User.fields" msgid "Phone" msgstr "电话" -#: service/models/user/user.ts:185 +#: service/models/user/user.ts:227 msgctxt "auth.model.User.fields" msgid "Preferences" msgstr "偏好设置" -#: service/models/user/user.ts:295 +#: service/models/user/user.ts:337 msgctxt "auth.model.User.fields" msgid "Roles" msgstr "角色" -#: service/models/user/user.ts:268 +#: service/models/user/user.ts:310 msgctxt "auth.model.User.fields" msgid "Sessions" msgstr "会话" -#: service/models/user/user.ts:172 +#: service/models/user/user.ts:214 msgctxt "auth.model.User.fields" msgid "Time Zone" msgstr "时区" -#: service/models/user/user.ts:279 +#: service/models/user/user.ts:321 msgctxt "auth.model.User.fields" msgid "Tokens" msgstr "令牌" -#: service/models/user/user.ts:65 +#: service/models/user/user.ts:104 msgctxt "auth.model.User.fields" msgid "Username" msgstr "用户名" -#: service/models/user/user.ts:159 -msgctxt "auth.model.User.fields" -msgid "When set, must match an active base.Language POSIX terminology code." -msgstr "若填写,须匹配已启用的 base.Language POSIX 术语代码。" - -#: service/models/user_role.ts:53 +#: service/models/user_role.ts:51 msgctxt "auth.model.UserRole.fields" msgid "Company" msgstr "公司" -#: service/models/user_role.ts:54 +#: service/models/user_role.ts:52 msgctxt "auth.model.UserRole.fields" msgid "Leave empty to grant the role in every company the user can access." msgstr "留空表示在用户可访问的每个公司中授予该角色。" -#: service/models/user_role.ts:40 +#: service/models/user_role.ts:38 msgctxt "auth.model.UserRole.fields" msgid "Role" msgstr "角色" -#: service/models/user_role.ts:29 +#: service/models/user_role.ts:27 msgctxt "auth.model.UserRole.fields" msgid "User" msgstr "用户" -#: service/models/session.ts:117 +#: service/models/session.ts:118 msgctxt "service/models/session" msgid "Failed to revoke session" msgstr "撤销会话失败" -#: service/models/session.ts:146 +#: service/models/session.ts:147 msgctxt "service/models/session" msgid "Failed to revoke user sessions" msgstr "撤销用户会话失败" @@ -621,152 +621,207 @@ msgctxt "service/models/token" msgid "userId is required" msgstr "userId 不能为空" -#: service/models/user/user.ts:930 +#: service/models/user/user.ts:77 +msgctxt "service/models/user" +msgid "%s requires a payload" +msgstr "%s 需要请求体" + +#: service/models/user/user.ts:1054 msgctxt "service/models/user" msgid "Failed to compute field rule spec" msgstr "计算字段规则规格失败" -#: service/models/user/user.ts:313 +#: service/models/user/user.ts:355 msgctxt "service/models/user" msgid "Invalid IANA timezone: %s" msgstr "无效的 IANA 时区:%s" -#: service/models/user/user.ts:911 +#: service/models/user/user.ts:1035 msgctxt "service/models/user" msgid "Invalid model full name; expected \".\"" msgstr "无效的模型全名;期望 \".\"" -#: service/models/user/user.ts:342 +#: service/models/user/user.ts:383 msgctxt "service/models/user" msgid "Invalid or inactive language: %s" msgstr "无效或未启用的语言:%s" -#: service/models/user/user.ts:656 +#: service/models/user/user.ts:464 +msgctxt "service/models/user" +msgid "Login requires a boolean RememberMe" +msgstr "登录需要布尔型 RememberMe" + +#: service/models/user/user.ts:476 +msgctxt "service/models/user" +msgid "Login requires a string DeviceInfo" +msgstr "登录需要字符串 DeviceInfo" + +#: service/models/user/user.ts:470 +msgctxt "service/models/user" +msgid "Login requires a string IpAddress" +msgstr "登录需要字符串 IpAddress" + +#: service/models/user/user.ts:458 +msgctxt "service/models/user" +msgid "Login requires username and password" +msgstr "登录需要用户名和密码" + +#: service/models/user/user.ts:785 msgctxt "service/models/user" msgid "Logout failed" msgstr "退出失败" -#: service/models/user/user.ts:895 +#: service/models/user/user.ts:766 +msgctxt "service/models/user" +msgid "Logout requires a boolean AllDevices" +msgstr "登出需要布尔型 AllDevices" + +#: service/models/user/user.ts:773 +msgctxt "service/models/user" +msgid "Logout requires a string DeviceInfo" +msgstr "登出需要字符串 DeviceInfo" + +#: service/models/user/user.ts:759 +msgctxt "service/models/user" +msgid "Logout requires a token string" +msgstr "登出需要令牌字符串" + +#: service/models/user/user.ts:1019 msgctxt "service/models/user" msgid "Missing identity information (userId)" msgstr "缺少身份信息(userId)" -#: service/models/user/user.ts:903 +#: service/models/user/user.ts:1027 msgctxt "service/models/user" msgid "Missing model name (model)" msgstr "缺少模型名称(model)" -#: service/models/user/user.ts:628 +#: service/models/user/user.ts:572 +msgctxt "service/models/user" +msgid "RefreshTokens requires a refresh token" +msgstr "刷新令牌需要 refresh token" + +#: service/models/user/user.ts:398 +msgctxt "service/models/user" +msgid "Register requires a User object" +msgstr "注册需要 User 对象" + +#: service/models/user/user.ts:404 +msgctxt "service/models/user" +msgid "Register requires a password string" +msgstr "注册需要密码字符串" + +#: service/models/user/user.ts:746 msgctxt "service/models/user" msgid "Switch company scope failed" msgstr "切换公司范围失败" -#: service/models/user/user.ts:645 +#: service/models/user/user.ts:603 msgctxt "service/models/user" -msgid "Token is required" -msgstr "令牌不能为空" +msgid "SwitchCompanyScope requires an ActiveCompanyId" +msgstr "切换公司范围需要 ActiveCompanyId" -#: service/models/user/user.ts:476 +#: service/models/user/user.ts:584 msgctxt "service/models/user" msgid "Token refresh failed" msgstr "令牌刷新失败" -#: service/models/user/user.ts:495 +#: service/models/user/user.ts:612 msgctxt "service/models/user" msgid "User is not logged in" msgstr "用户未登录" -#: service/models/user/user.ts:512 +#: service/models/user/user.ts:629 msgctxt "service/models/user" msgid "activeCompanyId cannot be empty" msgstr "activeCompanyId 不能为空" -#: service/models/user/user.ts:563 +#: service/models/user/user.ts:680 msgctxt "service/models/user" msgid "activeCompanyId is outside the allowed company scope" msgstr "activeCompanyId 不在允许的公司范围内" -#: service/models/user/user.ts:578 +#: service/models/user/user.ts:695 msgctxt "service/models/user" msgid "activeCompanyId must be included in enabledCompanyIds" msgstr "activeCompanyId 必须包含在 enabledCompanyIds 中" -#: service/models/user/user.ts:547 +#: service/models/user/user.ts:664 msgctxt "service/models/user" msgid "enabledCompanyIds contains an unauthorized company" msgstr "enabledCompanyIds 包含未授权的公司" -#: service/models/user/user.ts:531 +#: service/models/user/user.ts:648 msgctxt "service/models/user" msgid "enabledCompanyIds must be a string[] or omitted" msgstr "enabledCompanyIds 必须是 string[] 或省略" -#: service/models/user/_field_rule_eval.ts:126 +#: service/models/user/_field_rule_eval.ts:119 msgctxt "service/models/user/_field_rule_eval" msgid "Model does not exist" msgstr "模型不存在" -#: service/models/user/_lifecycle_auth.ts:269 service/models/user/_lifecycle_auth.ts:359 +#: service/models/user/_lifecycle_auth.ts:268 service/models/user/_lifecycle_auth.ts:358 msgctxt "service/models/user/_lifecycle_auth" msgid "Account is disabled" msgstr "账户已禁用" -#: service/models/user/_lifecycle_auth.ts:152 +#: service/models/user/_lifecycle_auth.ts:145 msgctxt "service/models/user/_lifecycle_auth" msgid "Email is already registered" msgstr "邮箱已被注册" -#: service/models/user/_lifecycle_auth.ts:345 service/models/user/_lifecycle_auth.ts:379 +#: service/models/user/_lifecycle_auth.ts:344 service/models/user/_lifecycle_auth.ts:378 msgctxt "service/models/user/_lifecycle_auth" msgid "Invalid token payload: missing user id" msgstr "无效的令牌载荷:缺少用户 ID" -#: service/models/user/_lifecycle_auth.ts:190 +#: service/models/user/_lifecycle_auth.ts:187 msgctxt "service/models/user/_lifecycle_auth" msgid "Registration failed: Main Company (Code=MAIN) was not found" msgstr "注册失败:未找到主公司(Code=MAIN)" -#: service/models/user/_lifecycle_auth.ts:215 +#: service/models/user/_lifecycle_auth.ts:212 msgctxt "service/models/user/_lifecycle_auth" msgid "Registration failed: base.user role is not initialized; load auth bootstrap data first" msgstr "注册失败:base.user 角色未初始化,请先加载 auth 引导数据" -#: service/models/user/_lifecycle_auth.ts:353 +#: service/models/user/_lifecycle_auth.ts:352 msgctxt "service/models/user/_lifecycle_auth" msgid "User not found" msgstr "用户不存在" -#: service/models/user/_lifecycle_auth.ts:253 service/models/user/_lifecycle_auth.ts:260 +#: service/models/user/_lifecycle_auth.ts:252 service/models/user/_lifecycle_auth.ts:259 msgctxt "service/models/user/_lifecycle_auth" msgid "User not found or password is incorrect" msgstr "用户不存在或密码错误" -#: service/models/user/_lifecycle_auth.ts:168 +#: service/models/user/_lifecycle_auth.ts:161 msgctxt "service/models/user/_lifecycle_auth" msgid "User registration failed: missing user id" msgstr "用户注册失败:缺少用户 ID" -#: service/models/user/_lifecycle_auth.ts:113 +#: service/models/user/_lifecycle_auth.ts:106 msgctxt "service/models/user/_lifecycle_auth" msgid "Username and password are required" msgstr "用户名和密码不能为空" -#: service/models/user/_lifecycle_auth.ts:140 +#: service/models/user/_lifecycle_auth.ts:133 msgctxt "service/models/user/_lifecycle_auth" msgid "Username is already in use" msgstr "用户名已被占用" -#: service/models/user/_lifecycle_auth.ts:133 +#: service/models/user/_lifecycle_auth.ts:126 msgctxt "service/models/user/_lifecycle_auth" msgid "Username is required" msgstr "用户名不能为空" -#: service/models/user/user.ts:425 +#: service/models/user/user.ts:513 msgctxt "service/models/user@Login" msgid "Login failed: unable to create token pair" msgstr "登录失败:无法创建令牌对" -#: service/models/user/user.ts:386 +#: service/models/user/user.ts:443 msgctxt "service/models/user@Register" msgid "User registration failed" msgstr "用户注册失败" @@ -816,12 +871,12 @@ msgctxt "web/components/layout/OSwitchCompany" msgid "Available Companies" msgstr "可用公司" -#: web/components/layout/OSwitchCompany.vue:282 +#: web/components/layout/OSwitchCompany.vue:289 msgctxt "web/components/layout/OSwitchCompany" msgid "Available companies must include the current company" msgstr "可选公司必须包含当前公司" -#: web/components/layout/OSwitchCompany.vue:247 web/components/layout/OSwitchCompany.vue:252 +#: web/components/layout/OSwitchCompany.vue:254 web/components/layout/OSwitchCompany.vue:259 msgctxt "web/components/layout/OSwitchCompany" msgid "Company" msgstr "公司" @@ -831,17 +886,17 @@ msgctxt "web/components/layout/OSwitchCompany" msgid "Current Company" msgstr "当前公司" -#: web/components/layout/OSwitchCompany.vue:287 +#: web/components/layout/OSwitchCompany.vue:294 msgctxt "web/components/layout/OSwitchCompany" msgid "No changes to apply" msgstr "没有可应用的更改" -#: web/components/layout/OSwitchCompany.vue:285 +#: web/components/layout/OSwitchCompany.vue:292 msgctxt "web/components/layout/OSwitchCompany" msgid "Only one company is available; nothing to apply" msgstr "仅有一家可选公司,无需应用" -#: web/components/layout/OSwitchCompany.vue:280 +#: web/components/layout/OSwitchCompany.vue:287 msgctxt "web/components/layout/OSwitchCompany" msgid "Select a current company" msgstr "请选择当前公司" @@ -866,7 +921,7 @@ msgctxt "web/components/preferences/OPreferencesDialog" msgid "Cancel" msgstr "取消" -#: web/components/preferences/OPreferencesDialog.vue:144 web/components/preferences/OPreferencesDialog.vue:153 +#: web/components/preferences/OPreferencesDialog.vue:156 web/components/preferences/OPreferencesDialog.vue:165 msgctxt "web/components/preferences/OPreferencesDialog" msgid "Cannot update preferences: missing user id" msgstr "无法更新偏好设置:缺少用户 ID" @@ -876,17 +931,22 @@ msgctxt "web/components/preferences/OPreferencesDialog" msgid "Edit Profile" msgstr "编辑个人资料" -#: web/components/preferences/OPreferencesDialog.vue:176 +#: web/components/preferences/OPreferencesDialog.vue:200 msgctxt "web/components/preferences/OPreferencesDialog" msgid "Failed to update preferences" msgstr "更新偏好设置失败" +#: web/components/preferences/OPreferencesDialog.vue:178 +msgctxt "web/components/preferences/OPreferencesDialog" +msgid "Invalid or inactive language" +msgstr "无效或未启用的语言" + #: web/components/preferences/OPreferencesDialog.vue:11 msgctxt "web/components/preferences/OPreferencesDialog" msgid "Language" msgstr "语言" -#: web/components/preferences/OPreferencesDialog.vue:173 +#: web/components/preferences/OPreferencesDialog.vue:197 msgctxt "web/components/preferences/OPreferencesDialog" msgid "Preferences updated" msgstr "偏好设置已更新" @@ -971,12 +1031,12 @@ msgctxt "web/pages/Login" msgid "Don't have an account?" msgstr "还没有账号?" -#: web/pages/Login.vue:20 web/pages/Login.vue:48 +#: web/pages/Login.vue:20 web/pages/Login.vue:50 msgctxt "web/pages/Login" msgid "Enter password" msgstr "请输入密码" -#: web/pages/Login.vue:16 web/pages/Login.vue:41 +#: web/pages/Login.vue:16 web/pages/Login.vue:43 msgctxt "web/pages/Login" msgid "Enter username" msgstr "请输入用户名" @@ -986,7 +1046,7 @@ msgctxt "web/pages/Login" msgid "Log In" msgstr "登录" -#: web/pages/Login.vue:116 +#: web/pages/Login.vue:114 msgctxt "web/pages/Login" msgid "Login failed. Please try again later." msgstr "登录失败,请稍后重试。" @@ -1416,77 +1476,82 @@ msgctxt "web/route/routes" msgid "User List" msgstr "用户列表" -#: web/stores/auth/actions.ts:344 +#: web/stores/auth/actions.ts:402 msgctxt "web/stores/auth/actions" msgid "Cannot load user details: missing userId" msgstr "无法加载用户详情:缺少 userId" -#: web/stores/auth/actions.ts:424 +#: web/stores/auth/actions.ts:487 msgctxt "web/stores/auth/actions" msgid "Failed to initialize auth" msgstr "初始化认证失败" -#: web/stores/auth/actions.ts:82 +#: web/stores/auth/actions.ts:108 msgctxt "web/stores/auth/actions" msgid "Failed to load permission state" msgstr "加载权限状态失败" -#: web/stores/auth/actions.ts:369 +#: web/stores/auth/actions.ts:432 msgctxt "web/stores/auth/actions" msgid "Failed to load user details" msgstr "加载用户详情失败" -#: web/stores/auth/actions.ts:324 +#: web/stores/auth/actions.ts:382 msgctxt "web/stores/auth/actions" msgid "Failed to refresh token" msgstr "刷新令牌失败" -#: web/stores/auth/actions.ts:128 +#: web/stores/auth/actions.ts:157 msgctxt "web/stores/auth/actions" msgid "Failed to switch company scope" msgstr "切换公司范围失败" -#: web/stores/auth/actions.ts:194 +#: web/stores/auth/actions.ts:246 msgctxt "web/stores/auth/actions" msgid "Invalid login response" msgstr "登录响应无效" -#: web/stores/auth/actions.ts:223 +#: web/stores/auth/actions.ts:275 msgctxt "web/stores/auth/actions" msgid "Login failed" msgstr "登录失败" -#: web/stores/auth/actions.ts:256 +#: web/stores/auth/actions.ts:312 msgctxt "web/stores/auth/actions" msgid "Logout failed, but local state was cleared" msgstr "退出失败,但本地状态已清除" -#: web/stores/auth/actions.ts:280 +#: web/stores/auth/actions.ts:336 msgctxt "web/stores/auth/actions" msgid "No refresh token is available" msgstr "没有可用的刷新令牌" -#: web/stores/auth/actions.ts:95 +#: web/stores/auth/actions.ts:121 msgctxt "web/stores/auth/actions" msgid "Not authenticated; cannot switch company scope" msgstr "未登录,无法切换公司范围" -#: web/stores/auth/actions.ts:292 +#: web/stores/auth/actions.ts:350 msgctxt "web/stores/auth/actions" msgid "RefreshTokens returned an invalid response" msgstr "刷新令牌返回了无效响应" -#: web/stores/auth/actions.ts:154 +#: web/stores/auth/actions.ts:192 +msgctxt "web/stores/auth/actions" +msgid "Register returned an invalid response" +msgstr "注册返回了无效响应" + +#: web/stores/auth/actions.ts:199 msgctxt "web/stores/auth/actions" msgid "Registration failed" msgstr "注册失败" -#: web/stores/auth/actions.ts:245 +#: web/stores/auth/actions.ts:301 msgctxt "web/stores/auth/actions" msgid "Server-side logout failed, but local state was cleared" msgstr "服务端退出失败,但本地状态已清除" -#: web/stores/auth/actions.ts:103 +#: web/stores/auth/actions.ts:132 msgctxt "web/stores/auth/actions" msgid "SwitchCompanyScope returned an invalid response" msgstr "切换公司范围返回了无效响应" @@ -2251,6 +2316,10 @@ msgctxt "web/views/UserSearchView" msgid "Active" msgstr "启用" +#~ msgctxt "auth.model.User.fields" +#~ msgid "When set, must match an active base.Language POSIX terminology code." +#~ msgstr "若填写,须匹配已启用的 base.Language POSIX 术语代码。" + #~ msgctxt "service/models/_user_field_rule_eval" #~ msgid "Model does not exist" #~ msgstr "模型不存在" @@ -2299,6 +2368,10 @@ msgstr "启用" #~ msgid "Username is required" #~ msgstr "用户名不能为空" +#~ msgctxt "service/models/user" +#~ msgid "Token is required" +#~ msgstr "令牌不能为空" + #~ msgctxt "web/views/RoleFieldRuleFormView" #~ msgid "Role is required. Leave Application/Model/Field empty for wider scopes (global / app / model)." #~ msgstr "角色为必填。应用/模型/字段留空表示更大作用域(全局 / 应用 / 模型)。" diff --git a/modules/auth/service/hook/post_upgrade.test.ts b/modules/auth/service/hook/post_upgrade.test.ts new file mode 100644 index 000000000..cacf4a6c4 --- /dev/null +++ b/modules/auth/service/hook/post_upgrade.test.ts @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { AuthUserLanguageHooks } from './post_upgrade'; + +test('AuthUserLanguageHooks.backfillUserLanguageId: sqlite SQL filters active trimmed codes', async () => { + const calls: Array<{ sql: string; params: string }> = []; + const prev = (globalThis as any).$choysum; + (globalThis as any).$choysum = { + ...(prev || {}), + db: { + dialectName: 'sqlite', + execute: async (sql: string, params: string) => { + calls.push({ sql, params }); + }, + }, + }; + try { + await AuthUserLanguageHooks.backfillUserLanguageId(); + } finally { + (globalThis as any).$choysum = prev; + } + expect(calls.length).toBe(1); + expect(calls[0].params).toBe('[]'); + expect(calls[0].sql).toContain('is_active = 1'); + expect(calls[0].sql).toContain('trim(auth_user.language)'); +}); + +test('AuthUserLanguageHooks.backfillUserLanguageId: postgres SQL filters active trimmed codes', async () => { + const calls: Array<{ sql: string; params: string }> = []; + const prev = (globalThis as any).$choysum; + (globalThis as any).$choysum = { + ...(prev || {}), + db: { + dialectName: 'postgres', + execute: async (sql: string, params: string) => { + calls.push({ sql, params }); + }, + }, + }; + try { + await AuthUserLanguageHooks.backfillUserLanguageId(); + } finally { + (globalThis as any).$choysum = prev; + } + expect(calls.length).toBe(1); + expect(calls[0].sql).toContain('is_active = true'); + expect(calls[0].sql).toContain('trim(auth_user.language)'); +}); + +test('AuthUserLanguageHooks.backfillUserLanguageId: ignores execute failures', async () => { + const prev = (globalThis as any).$choysum; + (globalThis as any).$choysum = { + ...(prev || {}), + db: { + dialectName: 'sqlite', + execute: async () => { + throw new Error('no language column'); + }, + }, + }; + try { + await AuthUserLanguageHooks.backfillUserLanguageId(); + } finally { + (globalThis as any).$choysum = prev; + } +}); + +test('AuthUserLanguageHooks.backfillUserLanguageId: no-ops without db', async () => { + const prev = (globalThis as any).$choysum; + (globalThis as any).$choysum = { ...(prev || {}), db: undefined }; + try { + await AuthUserLanguageHooks.backfillUserLanguageId(); + } finally { + (globalThis as any).$choysum = prev; + } +}); diff --git a/modules/auth/service/hook/post_upgrade.ts b/modules/auth/service/hook/post_upgrade.ts index 70aed6e70..e69a97f1c 100644 --- a/modules/auth/service/hook/post_upgrade.ts +++ b/modules/auth/service/hook/post_upgrade.ts @@ -17,7 +17,9 @@ export class AuthUserLanguageHooks { if (dialect === 'sqlite') { sql = `UPDATE auth_user SET language_id = ( - SELECT id FROM base_language WHERE code = auth_user.language LIMIT 1 + SELECT id FROM base_language + WHERE code = trim(auth_user.language) AND is_active = 1 + LIMIT 1 ) WHERE (language_id IS NULL OR language_id = '') AND language IS NOT NULL @@ -25,10 +27,12 @@ export class AuthUserLanguageHooks { } else if (dialect === 'postgres' || dialect === 'mysql') { sql = `UPDATE auth_user SET language_id = ( - SELECT id FROM base_language WHERE code = auth_user.language LIMIT 1 + SELECT id FROM base_language + WHERE code = trim(auth_user.language) AND is_active = true + LIMIT 1 ) WHERE COALESCE(language_id, '') = '' - AND COALESCE(language, '') <> ''`; + AND COALESCE(trim(language), '') <> ''`; } if (!sql) return; try { diff --git a/modules/auth/web/components/preferences/OPreferencesDialog.vue b/modules/auth/web/components/preferences/OPreferencesDialog.vue index 961567771..aaacb28b0 100644 --- a/modules/auth/web/components/preferences/OPreferencesDialog.vue +++ b/modules/auth/web/components/preferences/OPreferencesDialog.vue @@ -58,6 +58,7 @@ import { resolvePreferenceLanguage, resolvePreferenceTimezone, } from './preferences_defaults'; +import { resolveLanguageCodeFromId } from './preferences_language'; defineOptions({ name: 'OPreferencesDialog' }); @@ -124,15 +125,9 @@ async function loadTimezoneOptions() { async function syncLanguageFromUser() { const languageId = String(currentUser.value?.LanguageId || '').trim(); - let code = ''; - if (languageId) { - try { - const row = await (languageStore as any).Browse(languageId, ['Code']); - code = String(row?.Code || '').trim(); - } catch { - code = ''; - } - } + const code = await resolveLanguageCodeFromId(languageId, (id, fields) => + (languageStore as any).Browse(id, fields) + ); savedLanguageCode.value = code; const resolved = resolvePreferenceLanguage(code || null, i18nStore.terminologyLang); languageCode.value = resolved.code; @@ -159,7 +154,7 @@ async function openAndLoad() { // Fall back to whatever is already in auth state / identity. } } - syncLanguageFromUser(); + await syncLanguageFromUser(); await Promise.all([loadLanguageOptions(), loadTimezoneOptions()]); applyTimezoneFromUserOrBrowser(); } diff --git a/modules/auth/web/components/preferences/preferences_language.test.ts b/modules/auth/web/components/preferences/preferences_language.test.ts new file mode 100644 index 000000000..8416360a9 --- /dev/null +++ b/modules/auth/web/components/preferences/preferences_language.test.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { resolveLanguageCodeFromId } from './preferences_language'; + +test('resolveLanguageCodeFromId: empty id skips Browse', async () => { + let called = false; + const code = await resolveLanguageCodeFromId('', async () => { + called = true; + return { Code: 'zh_CN' }; + }); + expect(code).toBe(''); + expect(called).toBe(false); +}); + +test('resolveLanguageCodeFromId: returns Code from Browse', async () => { + const code = await resolveLanguageCodeFromId('lang_1', async () => ({ Code: ' en_US ' })); + expect(code).toBe('en_US'); +}); + +test('resolveLanguageCodeFromId: Browse errors become empty string', async () => { + const code = await resolveLanguageCodeFromId('lang_1', async () => { + throw new Error('boom'); + }); + expect(code).toBe(''); +}); diff --git a/modules/auth/web/components/preferences/preferences_language.ts b/modules/auth/web/components/preferences/preferences_language.ts new file mode 100644 index 000000000..de90279eb --- /dev/null +++ b/modules/auth/web/components/preferences/preferences_language.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +/** + * Resolve a language Code from User.LanguageId for the preferences dialog. + * Browse failures yield '' so the dialog can fall back to session terminology. + */ +export async function resolveLanguageCodeFromId( + languageId: unknown, + browseLanguage: (id: string, fields: string[]) => Promise<{ Code?: string } | null | undefined> +): Promise { + const id = String(languageId || '').trim(); + if (!id) return ''; + try { + const row = await browseLanguage(id, ['Code']); + return String(row?.Code || '').trim(); + } catch { + return ''; + } +} diff --git a/modules/auth/web/pages/Login.vue b/modules/auth/web/pages/Login.vue index 2c0e289a0..10f42b891 100644 --- a/modules/auth/web/pages/Login.vue +++ b/modules/auth/web/pages/Login.vue @@ -54,6 +54,7 @@ import { User, Lock } from '@element-plus/icons-vue'; import type { FormRules } from 'element-plus'; import { createTranslate } from '@/web/web/i18n'; import { useI18nStore, langToUiKey } from '@/web/web/stores/i18nStore'; +import { applyUserLanguagePreference } from '../stores/auth/language_preference'; import { runLoginAuthReady } from './login_auth_ready'; const { _t } = createTranslate('auth', { scope: 'web/pages/Login' }); @@ -133,18 +134,17 @@ async function handleLogin() { // Apply persisted User.LanguageId when present (terminology lang → UI locale). try { await authStore.loadUser(true); - const languageId = String((authStore.currentUser as any)?.LanguageId || '').trim(); const i18nStore = useI18nStore(); - if (languageId) { - const { createStoreByModel } = await import('@/web/web/stores/registry'); - const languageStore = createStoreByModel('base.Language'); - const row = await (languageStore as any).Browse(languageId, ['Code']); - const preferredLang = String(row?.Code || '').trim(); - if (preferredLang) { - await i18nStore.setUiKey(langToUiKey(preferredLang)); - } - } - i18nStore.setDisplayOverrides((authStore.currentUser as any)?.Preferences?.display ?? null); + const { createStoreByModel } = await import('@/web/web/stores/registry'); + const languageStore = createStoreByModel('base.Language'); + await applyUserLanguagePreference({ + languageId: (authStore.currentUser as any)?.LanguageId, + displayOverrides: (authStore.currentUser as any)?.Preferences?.display ?? null, + browseLanguage: (id, fields) => (languageStore as any).Browse(id, fields), + setUiKey: key => i18nStore.setUiKey(key), + setDisplayOverrides: overrides => i18nStore.setDisplayOverrides(overrides as any), + langToUiKey, + }); } catch { // Preference apply is best-effort; login already succeeded. } diff --git a/modules/auth/web/stores/auth/actions.test.ts b/modules/auth/web/stores/auth/actions.test.ts index 5bb99e95f..4b03c78e3 100644 --- a/modules/auth/web/stores/auth/actions.test.ts +++ b/modules/auth/web/stores/auth/actions.test.ts @@ -36,6 +36,7 @@ function buildMockState() { const Register = makeFn(); const RefreshTokens = makeFn(); const Browse = makeFn(); + const UpdateById = makeFn(); const GetPermissionState = makeFn(); const SwitchCompanyScope = makeFn(); return { @@ -68,10 +69,11 @@ function buildMockState() { Register: Register.fn, RefreshTokens: RefreshTokens.fn, Browse: Browse.fn, + UpdateById: UpdateById.fn, GetPermissionState: GetPermissionState.fn, SwitchCompanyScope: SwitchCompanyScope.fn, }, - _recorders: { Login, Logout, Register, RefreshTokens, Browse, GetPermissionState, SwitchCompanyScope }, + _recorders: { Login, Logout, Register, RefreshTokens, Browse, UpdateById, GetPermissionState, SwitchCompanyScope }, }; } @@ -385,3 +387,96 @@ test('logout: sends the logout envelope', async () => { DeviceInfo: 'device-1', }); }); + +test('loadUser: applies LanguageId via injected language store', async () => { + const mockState = buildMockState(); + const mockHelpers = buildMockHelpers(); + mockState.identity.value = { userId: 'usr_1' } as any; + mockState._recorders.Browse.resolve({ + Id: 'usr_1', + Username: 'ada', + Email: 'ada@example.com', + LanguageId: 'lang_zh', + Preferences: { display: { dateFormat: 'YYYY-MM-DD' } }, + }); + const langBrowse = makeFn(); + langBrowse.resolve({ Code: 'zh_CN' }); + + const actions = defineAuthActions(mockState as any, mockHelpers as any, { + isClient: true, + createLanguageStore: () => ({ + Browse: langBrowse.fn, + Search: async () => [], + }), + }); + + const ok = await actions.loadUser(true); + expect(ok).toBe(true); + expect(mockState._recorders.Browse.calls[0].args[1]).toEqual([ + 'Id', + 'Username', + 'Email', + 'LanguageId', + 'Timezone', + 'Preferences', + ]); + expect((mockState.currentUser.value as any)?.LanguageId).toBe('lang_zh'); +}); + +test('persistLanguagePreference: writes LanguageId and refreshes tokens', async () => { + const mockState = buildMockState(); + const mockHelpers = buildMockHelpers(); + mockState.isAuthenticated.value = true; + mockState.currentUser.value = { Id: 'usr_1', LanguageId: null } as any; + mockState.tokens.value = { accessToken: 'a', refreshToken: 'r', expiresAt: Date.now() + 3600_000 }; + mockState._recorders.UpdateById.resolve({ Id: 'usr_1', LanguageId: 'lang_en' }); + mockState._recorders.RefreshTokens.resolve({ + accessToken: 'a2', + refreshToken: 'r2', + expiresAt: Date.now() + 3600_000, + }); + const langSearch = makeFn(); + langSearch.resolve([{ Id: 'lang_en' }]); + + const actions = defineAuthActions(mockState as any, mockHelpers as any, { + isClient: true, + createLanguageStore: () => ({ + Browse: async () => null, + Search: langSearch.fn, + }), + }); + + await actions.persistLanguagePreference('en_US'); + expect(langSearch.calls.length).toBe(1); + expect(mockState._recorders.UpdateById.calls[0].args[1]).toEqual({ LanguageId: 'lang_en' }); + expect((mockState.currentUser.value as any).LanguageId).toBe('lang_en'); + expect(mockState._recorders.RefreshTokens.calls.length).toBe(1); +}); + +test('persistLanguagePreference: no-ops when anonymous or language missing', async () => { + const mockState = buildMockState(); + const mockHelpers = buildMockHelpers(); + const actions = defineAuthActions(mockState as any, mockHelpers as any, { + isClient: true, + createLanguageStore: () => ({ + Browse: async () => null, + Search: async () => { + throw new Error('should not search'); + }, + }), + }); + await actions.persistLanguagePreference('en_US'); + expect(mockState._recorders.UpdateById.calls.length).toBe(0); + + mockState.isAuthenticated.value = true; + mockState.currentUser.value = { Id: 'usr_1' } as any; + const actions2 = defineAuthActions(mockState as any, mockHelpers as any, { + isClient: true, + createLanguageStore: () => ({ + Browse: async () => null, + Search: async () => [], + }), + }); + await actions2.persistLanguagePreference('en_US'); + expect(mockState._recorders.UpdateById.calls.length).toBe(0); +}); diff --git a/modules/auth/web/stores/auth/actions.ts b/modules/auth/web/stores/auth/actions.ts index 70abb6bae..5c56f27ed 100644 --- a/modules/auth/web/stores/auth/actions.ts +++ b/modules/auth/web/stores/auth/actions.ts @@ -13,10 +13,15 @@ import type { PermissionState } from '@/auth/web/permission'; const { _t } = createTranslate('auth', { scope: 'web/stores/auth/actions' }); -/** Optional client/storage injection for FE unit tests. */ +/** Optional client/storage/language-store injection for FE unit tests. */ export type AuthActionDeps = { isClient?: boolean; clearAuthStorage?: () => void; + /** When set, skips dynamic registry lookup for base.Language. */ + createLanguageStore?: () => { + Browse: (id: string, fields?: string[]) => Promise<{ Code?: string } | null | undefined>; + Search: (domain: unknown, opts?: unknown) => Promise>; + }; }; /** @@ -30,6 +35,15 @@ export function defineAuthActions(state: AuthState, helpers: AuthHelpers, deps?: const clientSide = deps?.isClient ?? isClient; const clearStoredAuth = deps?.clearAuthStorage ?? (() => authStorage.clearAuthStorage()); + async function getLanguageStore() { + if (deps?.createLanguageStore) return deps.createLanguageStore(); + const { createStoreByModel } = await import('@/web/web/stores/registry'); + return createStoreByModel('base.Language') as { + Browse: (id: string, fields?: string[]) => Promise<{ Code?: string } | null | undefined>; + Search: (domain: unknown, opts?: unknown) => Promise>; + }; + } + /** * Resolve the device info payload that should be sent with auth RPCs. */ @@ -397,12 +411,17 @@ export function defineAuthActions(state: AuthState, helpers: AuthHelpers, deps?: // Align FE UI key with the user's LanguageId (covers initAuth refresh paths; Login also applies this). try { const { useI18nStore, langToUiKey } = await import('@/web/web/stores/i18nStore'); + const { applyUserLanguagePreference } = await import('./language_preference'); const i18nStore = useI18nStore(); - const preferredLang = await terminologyCodeFromLanguageId((user as any)?.LanguageId); - if (preferredLang) { - await i18nStore.setUiKey(langToUiKey(preferredLang)); - } - i18nStore.setDisplayOverrides((user as any)?.Preferences?.display ?? null); + const languageStore = await getLanguageStore(); + await applyUserLanguagePreference({ + languageId: (user as any)?.LanguageId, + displayOverrides: (user as any)?.Preferences?.display ?? null, + browseLanguage: (id, fields) => languageStore.Browse(id, fields), + setUiKey: key => i18nStore.setUiKey(key), + setDisplayOverrides: overrides => i18nStore.setDisplayOverrides(overrides as any), + langToUiKey, + }); } catch { // Best-effort; auth must not fail because of i18n wiring. } @@ -511,9 +530,8 @@ export function defineAuthActions(state: AuthState, helpers: AuthHelpers, deps?: if (!userId) { return; } - const { createStoreByModel } = await import('@/web/web/stores/registry'); - const languageStore = createStoreByModel('base.Language'); - const rows = (await (languageStore as any).Search( + const languageStore = await getLanguageStore(); + const rows = (await languageStore.Search( { And: [['Code', '=', terminologyLang], ['IsActive', '=', true]] } as any, { fields: ['Id'], limit: 1 } as any )) as Array<{ Id?: string }>; @@ -527,15 +545,6 @@ export function defineAuthActions(state: AuthState, helpers: AuthHelpers, deps?: await refreshTokenImpl(true); } - async function terminologyCodeFromLanguageId(languageId: unknown): Promise { - const id = String(languageId || '').trim(); - if (!id) return ''; - const { createStoreByModel } = await import('@/web/web/stores/registry'); - const languageStore = createStoreByModel('base.Language'); - const row = await (languageStore as any).Browse(id, ['Code']); - return String(row?.Code || '').trim(); - } - // Wrap public async actions with shared loading-state bookkeeping. const register = withLoading(registerImpl, state.loading); const login = withLoading(loginImpl, state.loading); diff --git a/modules/auth/web/stores/auth/language_preference.test.ts b/modules/auth/web/stores/auth/language_preference.test.ts new file mode 100644 index 000000000..9ef293e0e --- /dev/null +++ b/modules/auth/web/stores/auth/language_preference.test.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { applyUserLanguagePreference, terminologyCodeFromLanguageId } from './language_preference'; + +test('terminologyCodeFromLanguageId: empty id skips Browse', async () => { + let browsed = false; + const code = await terminologyCodeFromLanguageId('', async () => { + browsed = true; + return { Code: 'zh_CN' }; + }); + expect(code).toBe(''); + expect(browsed).toBe(false); +}); + +test('terminologyCodeFromLanguageId: returns trimmed Code from Browse', async () => { + const code = await terminologyCodeFromLanguageId(' lang_1 ', async (id, fields) => { + expect(id).toBe('lang_1'); + expect(fields).toEqual(['Code']); + return { Code: ' en_US ' }; + }); + expect(code).toBe('en_US'); +}); + +test('applyUserLanguagePreference: sets UI key and display overrides', async () => { + const setUiKeyCalls: string[] = []; + let overrides: unknown = 'unset'; + await applyUserLanguagePreference({ + languageId: 'lang_zh', + displayOverrides: { dateFormat: 'YYYY-MM-DD' }, + browseLanguage: async () => ({ Code: 'zh_CN' }), + setUiKey: async key => { + setUiKeyCalls.push(key); + }, + setDisplayOverrides: value => { + overrides = value; + }, + langToUiKey: lang => `ui:${lang}`, + }); + expect(setUiKeyCalls).toEqual(['ui:zh_CN']); + expect(overrides).toEqual({ dateFormat: 'YYYY-MM-DD' }); +}); + +test('applyUserLanguagePreference: skips setUiKey when LanguageId has no Code', async () => { + const setUiKeyCalls: string[] = []; + await applyUserLanguagePreference({ + languageId: 'lang_missing', + browseLanguage: async () => null, + setUiKey: async key => { + setUiKeyCalls.push(key); + }, + setDisplayOverrides: () => undefined, + langToUiKey: lang => lang, + }); + expect(setUiKeyCalls).toEqual([]); +}); diff --git a/modules/auth/web/stores/auth/language_preference.ts b/modules/auth/web/stores/auth/language_preference.ts new file mode 100644 index 000000000..375cdbe75 --- /dev/null +++ b/modules/auth/web/stores/auth/language_preference.ts @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +/** + * Resolve a terminology POSIX code from a base.Language id via Browse. + * Returns '' when the id is empty or Browse yields no Code. + */ +export async function terminologyCodeFromLanguageId( + languageId: unknown, + browseLanguage: (id: string, fields: string[]) => Promise<{ Code?: string } | null | undefined> +): Promise { + const id = String(languageId || '').trim(); + if (!id) return ''; + const row = await browseLanguage(id, ['Code']); + return String(row?.Code || '').trim(); +} + +/** + * Apply a User.LanguageId (and optional display overrides) to the FE i18n store. + * Best-effort: callers wrap this when login/auth must continue on i18n failure. + */ +export async function applyUserLanguagePreference(opts: { + languageId: unknown; + displayOverrides?: unknown; + browseLanguage: (id: string, fields: string[]) => Promise<{ Code?: string } | null | undefined>; + setUiKey: (uiKey: string) => Promise | void; + setDisplayOverrides: (overrides: unknown) => void; + langToUiKey: (terminologyLang: string) => string; +}): Promise { + const preferredLang = await terminologyCodeFromLanguageId(opts.languageId, opts.browseLanguage); + if (preferredLang) { + await opts.setUiKey(opts.langToUiKey(preferredLang)); + } + opts.setDisplayOverrides(opts.displayOverrides ?? null); +} diff --git a/modules/base/i18n/base.pot b/modules/base/i18n/base.pot index 0ad806cdd..e78e2e339 100644 --- a/modules/base/i18n/base.pot +++ b/modules/base/i18n/base.pot @@ -77,92 +77,92 @@ msgctxt "base.model.Bank.fields" msgid "Name" msgstr "" -#: service/models/city.ts:58 +#: service/models/city.ts:59 msgctxt "base.model.City.fields" msgid "Active" msgstr "" -#: service/models/city.ts:29 +#: service/models/city.ts:30 msgctxt "base.model.City.fields" msgid "Code" msgstr "" -#: service/models/city.ts:40 +#: service/models/city.ts:41 msgctxt "base.model.City.fields" msgid "Country" msgstr "" -#: service/models/city.ts:20 +#: service/models/city.ts:21 msgctxt "base.model.City.fields" msgid "Name" msgstr "" -#: service/models/city.ts:49 +#: service/models/city.ts:50 msgctxt "base.model.City.fields" msgid "State/Province" msgstr "" -#: service/models/company.ts:81 +#: service/models/company.ts:82 msgctxt "base.model.Company.fields" msgid "Active" msgstr "" -#: service/models/company.ts:107 +#: service/models/company.ts:108 msgctxt "base.model.Company.fields" msgid "Address" msgstr "" -#: service/models/company.ts:69 +#: service/models/company.ts:70 msgctxt "base.model.Company.fields" msgid "Base Currency" msgstr "" -#: service/models/company.ts:34 +#: service/models/company.ts:35 msgctxt "base.model.Company.fields" msgid "Code" msgstr "" -#: service/models/company.ts:99 +#: service/models/company.ts:100 msgctxt "base.model.Company.fields" msgid "Country" msgstr "" -#: service/models/company.ts:57 +#: service/models/company.ts:58 msgctxt "base.model.Company.fields" msgid "IANA timezone for business dates and scheduled jobs in this company." msgstr "" -#: service/models/company.ts:90 +#: service/models/company.ts:91 msgctxt "base.model.Company.fields" msgid "Language" msgstr "" -#: service/models/company.ts:24 +#: service/models/company.ts:25 msgctxt "base.model.Company.fields" msgid "Name" msgstr "" -#: service/models/company.ts:44 +#: service/models/company.ts:45 msgctxt "base.model.Company.fields" msgid "Parent Company" msgstr "" -#: service/models/company.ts:45 +#: service/models/company.ts:46 msgctxt "base.model.Company.fields" msgid "Parent in the company tree; cannot be self or a descendant." msgstr "" -#: service/models/company.ts:70 +#: service/models/company.ts:71 msgctxt "base.model.Company.fields" msgid "Reporting currency; foreign amounts convert to this currency." msgstr "" -#: service/models/company.ts:35 +#: service/models/company.ts:36 msgctxt "base.model.Company.fields" msgid "Short unique code used in references and integrations." msgstr "" -#: service/models/company.ts:56 +#: service/models/company.ts:57 msgctxt "base.model.Company.fields" msgid "Time Zone" msgstr "" @@ -222,47 +222,47 @@ msgctxt "base.model.Country.fields" msgid "ZIP Required" msgstr "" -#: service/models/currency.ts:89 +#: service/models/currency.ts:99 msgctxt "base.model.Currency.fields" msgid "Active" msgstr "" -#: service/models/currency.ts:51 +#: service/models/currency.ts:61 msgctxt "base.model.Currency.fields" msgid "Code" msgstr "" -#: service/models/currency.ts:67 +#: service/models/currency.ts:77 msgctxt "base.model.Currency.fields" msgid "Decimal Digits" msgstr "" -#: service/models/currency.ts:68 +#: service/models/currency.ts:78 msgctxt "base.model.Currency.fields" msgid "Decimal places used when rounding amounts in this currency." msgstr "" -#: service/models/currency.ts:52 +#: service/models/currency.ts:62 msgctxt "base.model.Currency.fields" msgid "ISO 4217 code (e.g. USD, EUR)." msgstr "" -#: service/models/currency.ts:41 +#: service/models/currency.ts:51 msgctxt "base.model.Currency.fields" msgid "Name" msgstr "" -#: service/models/currency.ts:77 +#: service/models/currency.ts:87 msgctxt "base.model.Currency.fields" msgid "Rounding Precision" msgstr "" -#: service/models/currency.ts:78 +#: service/models/currency.ts:88 msgctxt "base.model.Currency.fields" msgid "Smallest rounding increment; must be greater than zero." msgstr "" -#: service/models/currency.ts:59 +#: service/models/currency.ts:69 msgctxt "base.model.Currency.fields" msgid "Symbol" msgstr "" @@ -302,167 +302,167 @@ msgctxt "base.model.ExchangeRate.fields" msgid "Units of this currency per one unit of the company base currency." msgstr "" -#: service/models/language.ts:72 +#: service/models/language.ts:73 msgctxt "base.model.Language.fields" msgid "Active" msgstr "" -#: service/models/language.ts:139 +#: service/models/language.ts:140 msgctxt "base.model.Language.fields" msgid "After amount" msgstr "" -#: service/models/language.ts:138 +#: service/models/language.ts:139 msgctxt "base.model.Language.fields" msgid "Before amount" msgstr "" -#: service/models/language.ts:60 +#: service/models/language.ts:61 msgctxt "base.model.Language.fields" msgid "Code" msgstr "" -#: service/models/language.ts:143 +#: service/models/language.ts:144 msgctxt "base.model.Language.fields" msgid "Currency Symbol Position" msgstr "" -#: service/models/language.ts:115 +#: service/models/language.ts:116 msgctxt "base.model.Language.fields" msgid "Date Format" msgstr "" -#: service/models/language.ts:90 +#: service/models/language.ts:91 msgctxt "base.model.Language.fields" msgid "Decimal Separator" msgstr "" -#: service/models/language.ts:106 +#: service/models/language.ts:107 msgctxt "base.model.Language.fields" msgid "Digit grouping pattern, default [3,0] (thousands then none)." msgstr "" -#: service/models/language.ts:83 +#: service/models/language.ts:84 msgctxt "base.model.Language.fields" msgid "Direction" msgstr "" -#: service/models/language.ts:131 +#: service/models/language.ts:132 msgctxt "base.model.Language.fields" msgid "First Day of Week" msgstr "" -#: service/models/language.ts:105 +#: service/models/language.ts:106 msgctxt "base.model.Language.fields" msgid "Grouping" msgstr "" -#: service/models/language.ts:79 +#: service/models/language.ts:80 msgctxt "base.model.Language.fields" msgid "Left to right" msgstr "" -#: service/models/language.ts:50 +#: service/models/language.ts:51 msgctxt "base.model.Language.fields" msgid "Name" msgstr "" -#: service/models/language.ts:61 +#: service/models/language.ts:62 msgctxt "base.model.Language.fields" msgid "POSIX locale code (e.g. en_US); cannot be changed after create." msgstr "" -#: service/models/language.ts:80 +#: service/models/language.ts:81 msgctxt "base.model.Language.fields" msgid "Right to left" msgstr "" -#: service/models/language.ts:150 +#: service/models/language.ts:151 msgctxt "base.model.Language.fields" msgid "Symbol Spacing" msgstr "" -#: service/models/language.ts:97 +#: service/models/language.ts:98 msgctxt "base.model.Language.fields" msgid "Thousands Separator" msgstr "" -#: service/models/language.ts:125 +#: service/models/language.ts:126 msgctxt "base.model.Language.fields" msgid "Time Format" msgstr "" -#: service/models/language.ts:116 +#: service/models/language.ts:117 msgctxt "base.model.Language.fields" msgid "strftime-style pattern for dates in this language." msgstr "" -#: service/models/sequence.ts:127 +#: service/models/sequence.ts:130 msgctxt "base.model.Sequence.fields" msgid "Active" msgstr "" -#: service/models/sequence.ts:51 +#: service/models/sequence.ts:54 msgctxt "base.model.Sequence.fields" msgid "Code" msgstr "" -#: service/models/sequence.ts:62 +#: service/models/sequence.ts:65 msgctxt "base.model.Sequence.fields" msgid "Company" msgstr "" -#: service/models/sequence.ts:63 +#: service/models/sequence.ts:66 msgctxt "base.model.Sequence.fields" msgid "Leave empty for a global sequence shared by all companies." msgstr "" -#: service/models/sequence.ts:93 +#: service/models/sequence.ts:96 msgctxt "base.model.Sequence.fields" msgid "Literal text appended to the generated number." msgstr "" -#: service/models/sequence.ts:83 +#: service/models/sequence.ts:86 msgctxt "base.model.Sequence.fields" msgid "Literal text prepended to the generated number." msgstr "" -#: service/models/sequence.ts:41 +#: service/models/sequence.ts:44 msgctxt "base.model.Sequence.fields" msgid "Name" msgstr "" -#: service/models/sequence.ts:115 +#: service/models/sequence.ts:118 msgctxt "base.model.Sequence.fields" msgid "Next Number" msgstr "" -#: service/models/sequence.ts:116 +#: service/models/sequence.ts:119 msgctxt "base.model.Sequence.fields" msgid "Next issued number; lowering it may reuse already-assigned values." msgstr "" -#: service/models/sequence.ts:103 +#: service/models/sequence.ts:106 msgctxt "base.model.Sequence.fields" msgid "Padding Length" msgstr "" -#: service/models/sequence.ts:82 +#: service/models/sequence.ts:85 msgctxt "base.model.Sequence.fields" msgid "Prefix" msgstr "" -#: service/models/sequence.ts:52 +#: service/models/sequence.ts:55 msgctxt "base.model.Sequence.fields" msgid "Stable id passed to Sequence.Next; unique per company or globally." msgstr "" -#: service/models/sequence.ts:92 +#: service/models/sequence.ts:95 msgctxt "base.model.Sequence.fields" msgid "Suffix" msgstr "" -#: service/models/sequence.ts:104 +#: service/models/sequence.ts:107 msgctxt "base.model.Sequence.fields" msgid "Zero-padded numeric width between prefix and suffix." msgstr "" @@ -522,67 +522,67 @@ msgctxt "base.model.SequenceIdempotency.fields" msgid "Start" msgstr "" -#: service/models/state.ts:47 +#: service/models/state.ts:48 msgctxt "base.model.State.fields" msgid "Active" msgstr "" -#: service/models/state.ts:27 +#: service/models/state.ts:28 msgctxt "base.model.State.fields" msgid "Code" msgstr "" -#: service/models/state.ts:38 +#: service/models/state.ts:39 msgctxt "base.model.State.fields" msgid "Country" msgstr "" -#: service/models/state.ts:18 +#: service/models/state.ts:19 msgctxt "base.model.State.fields" msgid "Name" msgstr "" -#: service/models/uom.ts:84 +#: service/models/uom.ts:96 msgctxt "base.model.UoM.fields" msgid "Active" msgstr "" -#: service/models/uom.ts:37 +#: service/models/uom.ts:49 msgctxt "base.model.UoM.fields" msgid "Category" msgstr "" -#: service/models/uom.ts:66 +#: service/models/uom.ts:78 msgctxt "base.model.UoM.fields" msgid "Conversion Factor" msgstr "" -#: service/models/uom.ts:67 +#: service/models/uom.ts:79 msgctxt "base.model.UoM.fields" msgid "Multiplier to convert this unit to the category reference unit." msgstr "" -#: service/models/uom.ts:19 +#: service/models/uom.ts:31 msgctxt "base.model.UoM.fields" msgid "Name" msgstr "" -#: service/models/uom.ts:46 +#: service/models/uom.ts:58 msgctxt "base.model.UoM.fields" msgid "One reference unit per category; its factor must be 1." msgstr "" -#: service/models/uom.ts:45 +#: service/models/uom.ts:57 msgctxt "base.model.UoM.fields" msgid "Reference Unit" msgstr "" -#: service/models/uom.ts:75 +#: service/models/uom.ts:87 msgctxt "base.model.UoM.fields" msgid "Rounding" msgstr "" -#: service/models/uom.ts:26 +#: service/models/uom.ts:38 msgctxt "base.model.UoM.fields" msgid "Symbol" msgstr "" @@ -617,49 +617,49 @@ msgctxt "service/models/_base_bridge" msgid "Name is required" msgstr "" -#: service/models/_currency_convert.ts:103 -#: service/models/_currency_convert.ts:106 +#: service/models/_currency_convert.ts:120 +#: service/models/_currency_convert.ts:125 msgctxt "service/models/_currency_convert" msgid "Company not found" msgstr "" -#: service/models/_currency_convert.ts:110 +#: service/models/_currency_convert.ts:134 msgctxt "service/models/_currency_convert" msgid "Company.CurrencyId is required" msgstr "" -#: service/models/_currency_convert.ts:71 +#: service/models/_currency_convert.ts:89 msgctxt "service/models/_currency_convert" msgid "CompanyId/FromCurrencyId/ToCurrencyId are required" msgstr "" -#: service/models/_currency_convert.ts:80 +#: service/models/_currency_convert.ts:97 msgctxt "service/models/_currency_convert" msgid "Date is invalid" msgstr "" -#: service/models/_currency_convert.ts:79 +#: service/models/_currency_convert.ts:96 msgctxt "service/models/_currency_convert" msgid "Date is required" msgstr "" -#: service/models/_currency_convert.ts:81 +#: service/models/_currency_convert.ts:98 msgctxt "service/models/_currency_convert" msgid "Date must be YYYY-MM-DD" msgstr "" -#: service/models/_currency_convert.ts:133 -#: service/models/_currency_convert.ts:152 +#: service/models/_currency_convert.ts:159 +#: service/models/_currency_convert.ts:180 msgctxt "service/models/_currency_convert" msgid "ExchangeRate not found for currency %s" msgstr "" -#: service/models/_currency_convert.ts:86 +#: service/models/_currency_convert.ts:103 msgctxt "service/models/_currency_convert" msgid "Invalid Amount" msgstr "" -#: service/models/_currency_convert.ts:174 +#: service/models/_currency_convert.ts:204 msgctxt "service/models/_currency_convert" msgid "Target currency %s not found" msgstr "" @@ -704,57 +704,77 @@ msgctxt "service/models/_sequence_cleanup" msgid "OlderThan must be YYYY-MM-DD" msgstr "" -#: service/models/_sequence_next.ts:23 +#: service/models/_sequence_next.ts:32 msgctxt "service/models/_sequence_next" msgid "Count must be an integer >= 1" msgstr "" -#: service/models/_sequence_next.ts:26 +#: service/models/_sequence_next.ts:35 msgctxt "service/models/_sequence_next" msgid "Count must be within 1..1000" msgstr "" -#: service/models/_sequence_next.ts:74 +#: service/models/_sequence_next.ts:94 msgctxt "service/models/_sequence_next" msgid "IdempotencyKey conflict with different request" msgstr "" -#: service/models/_sequence_next.ts:37 +#: service/models/_sequence_next.ts:46 msgctxt "service/models/_sequence_next" msgid "IdempotencyKey is invalid" msgstr "" -#: service/models/_sequence_next.ts:36 +#: service/models/_sequence_next.ts:45 msgctxt "service/models/_sequence_next" msgid "IdempotencyKey is too long" msgstr "" -#: service/models/_sequence_next.ts:35 +#: service/models/_sequence_next.ts:44 msgctxt "service/models/_sequence_next" msgid "IdempotencyKey must be non-empty" msgstr "" -#: service/models/_sequence_next.ts:152 +#: service/models/_sequence_next.ts:192 msgctxt "service/models/_sequence_next" msgid "Sequence allocation conflicted; please retry" msgstr "" -#: service/models/_sequence_next.ts:172 +#: service/models/_sequence_next.ts:208 msgctxt "service/models/_sequence_next" msgid "Sequence is inactive" msgstr "" -#: service/models/_sequence_next.ts:130 -#: service/models/_sequence_next.ts:197 +#: service/models/_sequence_next.ts:168 +#: service/models/_sequence_next.ts:232 msgctxt "service/models/_sequence_next" msgid "Sequence not found" msgstr "" -#: service/models/_sequence_next.ts:118 +#: service/models/_sequence_next.ts:156 msgctxt "service/models/_sequence_next" msgid "Sequence not found for Code=%s" msgstr "" +#: service/models/_uom_convert.ts:56 +msgctxt "service/models/_uom_convert" +msgid "%s not found" +msgstr "" + +#: service/models/_uom_convert.ts:51 +msgctxt "service/models/_uom_convert" +msgid "Invalid Amount" +msgstr "" + +#: service/models/_uom_convert.ts:71 +msgctxt "service/models/_uom_convert" +msgid "UoM factor must be greater than 0" +msgstr "" + +#: service/models/_uom_convert.ts:65 +msgctxt "service/models/_uom_convert" +msgid "UoMs must belong to the same category" +msgstr "" + #: service/models/address.ts:115 msgctxt "service/models/address" msgid "City not found" @@ -795,52 +815,52 @@ msgctxt "service/models/address" msgid "Zip is required for this country" msgstr "" -#: service/models/city.ts:87 +#: service/models/city.ts:93 msgctxt "service/models/city" msgid "City Code must be unique within Country + State" msgstr "" -#: service/models/city.ts:67 +#: service/models/city.ts:68 msgctxt "service/models/city" msgid "State not found" msgstr "" -#: service/models/city.ts:68 +#: service/models/city.ts:69 msgctxt "service/models/city" msgid "State.CountryId must equal City.CountryId" msgstr "" -#: service/models/company.ts:114 +#: service/models/company.ts:115 msgctxt "service/models/company" msgid "%s is required" msgstr "" -#: service/models/company.ts:129 +#: service/models/company.ts:128 msgctxt "service/models/company" msgid "Company Code must be unique" msgstr "" -#: service/models/company.ts:138 +#: service/models/company.ts:137 msgctxt "service/models/company" msgid "CurrencyId is required" msgstr "" -#: service/models/company.ts:149 +#: service/models/company.ts:148 msgctxt "service/models/company" msgid "Invalid IANA timezone: %s" msgstr "" -#: service/models/company.ts:172 +#: service/models/company.ts:171 msgctxt "service/models/company" msgid "ParentId cannot be a descendant of the company" msgstr "" -#: service/models/company.ts:159 +#: service/models/company.ts:158 msgctxt "service/models/company" msgid "ParentId cannot be self" msgstr "" -#: service/models/company.ts:146 +#: service/models/company.ts:145 msgctxt "service/models/company" msgid "Timezone is required" msgstr "" @@ -850,27 +870,27 @@ msgctxt "service/models/country" msgid "AddressFormat contains unsupported token: %s" msgstr "" -#: service/models/currency.ts:100 +#: service/models/currency.ts:110 msgctxt "service/models/currency" msgid "DecimalDigits is required" msgstr "" -#: service/models/currency.ts:101 +#: service/models/currency.ts:111 msgctxt "service/models/currency" msgid "DecimalDigits must be a non-negative integer" msgstr "" -#: service/models/currency.ts:108 +#: service/models/currency.ts:118 msgctxt "service/models/currency" msgid "Rounding must be a valid decimal" msgstr "" -#: service/models/currency.ts:107 +#: service/models/currency.ts:117 msgctxt "service/models/currency" msgid "Rounding must be greater than 0" msgstr "" -#: service/models/exchange_rate.ts:106 +#: service/models/exchange_rate.ts:109 msgctxt "service/models/exchange_rate" msgid "%s is required" msgstr "" @@ -890,42 +910,42 @@ msgctxt "service/models/exchange_rate" msgid "Date must be YYYY-MM-DD" msgstr "" -#: service/models/exchange_rate.ts:123 +#: service/models/exchange_rate.ts:124 msgctxt "service/models/exchange_rate" msgid "ExchangeRate must be unique for CompanyId + CurrencyId + Date" msgstr "" -#: service/models/exchange_rate.ts:133 +#: service/models/exchange_rate.ts:134 msgctxt "service/models/exchange_rate" msgid "Rate must be a valid decimal" msgstr "" -#: service/models/exchange_rate.ts:132 +#: service/models/exchange_rate.ts:133 msgctxt "service/models/exchange_rate" msgid "Rate must be greater than 0" msgstr "" -#: service/models/language.ts:256 +#: service/models/language.ts:259 msgctxt "service/models/language" msgid "At least one language must stay active" msgstr "" -#: service/models/language.ts:199 +#: service/models/language.ts:200 msgctxt "service/models/language" msgid "Code or LanguageId is required" msgstr "" -#: service/models/language.ts:270 +#: service/models/language.ts:273 msgctxt "service/models/language" msgid "Language code cannot be changed" msgstr "" -#: service/models/language.ts:220 +#: service/models/language.ts:221 msgctxt "service/models/language" msgid "Language not found" msgstr "" -#: service/models/language.ts:273 +#: service/models/language.ts:276 msgctxt "service/models/language" msgid "The root language en_US cannot be deactivated" msgstr "" @@ -960,42 +980,42 @@ msgctxt "service/models/sequence_idempotency" msgid "SequenceId is required" msgstr "" -#: service/models/state.ts:67 +#: service/models/state.ts:72 msgctxt "service/models/state" msgid "State Code must be unique within Country" msgstr "" -#: service/models/uom.ts:102 +#: service/models/uom.ts:114 msgctxt "service/models/uom" msgid "Each UoM category can only have one reference unit" msgstr "" -#: service/models/uom.ts:108 +#: service/models/uom.ts:120 msgctxt "service/models/uom" msgid "Each UoM category must have one reference unit" msgstr "" -#: service/models/uom.ts:122 +#: service/models/uom.ts:141 msgctxt "service/models/uom" msgid "Factor must be a valid decimal" msgstr "" -#: service/models/uom.ts:121 +#: service/models/uom.ts:140 msgctxt "service/models/uom" msgid "Factor must be greater than 0" msgstr "" -#: service/models/uom.ts:125 +#: service/models/uom.ts:144 msgctxt "service/models/uom" msgid "Reference UoM must have Factor = 1" msgstr "" -#: service/models/uom.ts:134 +#: service/models/uom.ts:153 msgctxt "service/models/uom" msgid "Rounding must be a valid decimal" msgstr "" -#: service/models/uom.ts:133 +#: service/models/uom.ts:152 msgctxt "service/models/uom" msgid "Rounding must be greater than 0" msgstr "" diff --git a/modules/base/i18n/zh_CN.po b/modules/base/i18n/zh_CN.po index 1c118f09d..d6dfc1878 100644 --- a/modules/base/i18n/zh_CN.po +++ b/modules/base/i18n/zh_CN.po @@ -71,92 +71,92 @@ msgctxt "base.model.Bank.fields" msgid "Name" msgstr "名称" -#: service/models/city.ts:58 +#: service/models/city.ts:59 msgctxt "base.model.City.fields" msgid "Active" msgstr "启用" -#: service/models/city.ts:29 +#: service/models/city.ts:30 msgctxt "base.model.City.fields" msgid "Code" msgstr "编码" -#: service/models/city.ts:40 +#: service/models/city.ts:41 msgctxt "base.model.City.fields" msgid "Country" msgstr "国家/地区" -#: service/models/city.ts:20 +#: service/models/city.ts:21 msgctxt "base.model.City.fields" msgid "Name" msgstr "名称" -#: service/models/city.ts:49 +#: service/models/city.ts:50 msgctxt "base.model.City.fields" msgid "State/Province" msgstr "州/省" -#: service/models/company.ts:81 +#: service/models/company.ts:82 msgctxt "base.model.Company.fields" msgid "Active" msgstr "启用" -#: service/models/company.ts:107 +#: service/models/company.ts:108 msgctxt "base.model.Company.fields" msgid "Address" msgstr "地址" -#: service/models/company.ts:69 +#: service/models/company.ts:70 msgctxt "base.model.Company.fields" msgid "Base Currency" msgstr "本位币" -#: service/models/company.ts:34 +#: service/models/company.ts:35 msgctxt "base.model.Company.fields" msgid "Code" msgstr "编码" -#: service/models/company.ts:99 +#: service/models/company.ts:100 msgctxt "base.model.Company.fields" msgid "Country" msgstr "国家/地区" -#: service/models/company.ts:57 +#: service/models/company.ts:58 msgctxt "base.model.Company.fields" msgid "IANA timezone for business dates and scheduled jobs in this company." msgstr "该公司业务日期与计划任务使用的 IANA 时区。" -#: service/models/company.ts:90 +#: service/models/company.ts:91 msgctxt "base.model.Company.fields" msgid "Language" msgstr "语言" -#: service/models/company.ts:24 +#: service/models/company.ts:25 msgctxt "base.model.Company.fields" msgid "Name" msgstr "名称" -#: service/models/company.ts:44 +#: service/models/company.ts:45 msgctxt "base.model.Company.fields" msgid "Parent Company" msgstr "上级公司" -#: service/models/company.ts:45 +#: service/models/company.ts:46 msgctxt "base.model.Company.fields" msgid "Parent in the company tree; cannot be self or a descendant." msgstr "公司树中的上级公司;不能是自身或下级。" -#: service/models/company.ts:70 +#: service/models/company.ts:71 msgctxt "base.model.Company.fields" msgid "Reporting currency; foreign amounts convert to this currency." msgstr "报表货币;外币金额会换算到此货币。" -#: service/models/company.ts:35 +#: service/models/company.ts:36 msgctxt "base.model.Company.fields" msgid "Short unique code used in references and integrations." msgstr "用于引用与集成的短唯一编码。" -#: service/models/company.ts:56 +#: service/models/company.ts:57 msgctxt "base.model.Company.fields" msgid "Time Zone" msgstr "时区" @@ -216,47 +216,47 @@ msgctxt "base.model.Country.fields" msgid "ZIP Required" msgstr "邮政编码必填" -#: service/models/currency.ts:89 +#: service/models/currency.ts:99 msgctxt "base.model.Currency.fields" msgid "Active" msgstr "启用" -#: service/models/currency.ts:51 +#: service/models/currency.ts:61 msgctxt "base.model.Currency.fields" msgid "Code" msgstr "编码" -#: service/models/currency.ts:67 +#: service/models/currency.ts:77 msgctxt "base.model.Currency.fields" msgid "Decimal Digits" msgstr "小数位数" -#: service/models/currency.ts:68 +#: service/models/currency.ts:78 msgctxt "base.model.Currency.fields" msgid "Decimal places used when rounding amounts in this currency." msgstr "该货币金额舍入时使用的小数位数。" -#: service/models/currency.ts:52 +#: service/models/currency.ts:62 msgctxt "base.model.Currency.fields" msgid "ISO 4217 code (e.g. USD, EUR)." msgstr "ISO 4217 货币代码(如 USD、EUR)。" -#: service/models/currency.ts:41 +#: service/models/currency.ts:51 msgctxt "base.model.Currency.fields" msgid "Name" msgstr "名称" -#: service/models/currency.ts:77 +#: service/models/currency.ts:87 msgctxt "base.model.Currency.fields" msgid "Rounding Precision" msgstr "舍入精度" -#: service/models/currency.ts:78 +#: service/models/currency.ts:88 msgctxt "base.model.Currency.fields" msgid "Smallest rounding increment; must be greater than zero." msgstr "最小舍入步长;必须大于零。" -#: service/models/currency.ts:59 +#: service/models/currency.ts:69 msgctxt "base.model.Currency.fields" msgid "Symbol" msgstr "符号" @@ -296,167 +296,167 @@ msgctxt "base.model.ExchangeRate.fields" msgid "Units of this currency per one unit of the company base currency." msgstr "每 1 单位公司本位币对应的该币种数量。" -#: service/models/language.ts:72 +#: service/models/language.ts:73 msgctxt "base.model.Language.fields" msgid "Active" msgstr "启用" -#: service/models/language.ts:139 +#: service/models/language.ts:140 msgctxt "base.model.Language.fields" msgid "After amount" msgstr "金额后" -#: service/models/language.ts:138 +#: service/models/language.ts:139 msgctxt "base.model.Language.fields" msgid "Before amount" msgstr "金额前" -#: service/models/language.ts:60 +#: service/models/language.ts:61 msgctxt "base.model.Language.fields" msgid "Code" msgstr "编码" -#: service/models/language.ts:143 +#: service/models/language.ts:144 msgctxt "base.model.Language.fields" msgid "Currency Symbol Position" msgstr "货币符号位置" -#: service/models/language.ts:115 +#: service/models/language.ts:116 msgctxt "base.model.Language.fields" msgid "Date Format" msgstr "日期格式" -#: service/models/language.ts:90 +#: service/models/language.ts:91 msgctxt "base.model.Language.fields" msgid "Decimal Separator" msgstr "小数分隔符" -#: service/models/language.ts:106 +#: service/models/language.ts:107 msgctxt "base.model.Language.fields" msgid "Digit grouping pattern, default [3,0] (thousands then none)." msgstr "数字分组模式,默认 [3,0](千分位后不再分组)。" -#: service/models/language.ts:83 +#: service/models/language.ts:84 msgctxt "base.model.Language.fields" msgid "Direction" msgstr "文字方向" -#: service/models/language.ts:131 +#: service/models/language.ts:132 msgctxt "base.model.Language.fields" msgid "First Day of Week" msgstr "每周第一天" -#: service/models/language.ts:105 +#: service/models/language.ts:106 msgctxt "base.model.Language.fields" msgid "Grouping" msgstr "分组" -#: service/models/language.ts:79 +#: service/models/language.ts:80 msgctxt "base.model.Language.fields" msgid "Left to right" msgstr "从左到右" -#: service/models/language.ts:50 +#: service/models/language.ts:51 msgctxt "base.model.Language.fields" msgid "Name" msgstr "名称" -#: service/models/language.ts:61 +#: service/models/language.ts:62 msgctxt "base.model.Language.fields" msgid "POSIX locale code (e.g. en_US); cannot be changed after create." msgstr "POSIX 区域代码(如 en_US);创建后不可更改。" -#: service/models/language.ts:80 +#: service/models/language.ts:81 msgctxt "base.model.Language.fields" msgid "Right to left" msgstr "从右到左" -#: service/models/language.ts:150 +#: service/models/language.ts:151 msgctxt "base.model.Language.fields" msgid "Symbol Spacing" msgstr "符号间距" -#: service/models/language.ts:97 +#: service/models/language.ts:98 msgctxt "base.model.Language.fields" msgid "Thousands Separator" msgstr "千位分隔符" -#: service/models/language.ts:125 +#: service/models/language.ts:126 msgctxt "base.model.Language.fields" msgid "Time Format" msgstr "时间格式" -#: service/models/language.ts:116 +#: service/models/language.ts:117 msgctxt "base.model.Language.fields" msgid "strftime-style pattern for dates in this language." msgstr "该语言下的 strftime 风格日期格式。" -#: service/models/sequence.ts:127 +#: service/models/sequence.ts:130 msgctxt "base.model.Sequence.fields" msgid "Active" msgstr "启用" -#: service/models/sequence.ts:51 +#: service/models/sequence.ts:54 msgctxt "base.model.Sequence.fields" msgid "Code" msgstr "编码" -#: service/models/sequence.ts:62 +#: service/models/sequence.ts:65 msgctxt "base.model.Sequence.fields" msgid "Company" msgstr "公司" -#: service/models/sequence.ts:63 +#: service/models/sequence.ts:66 msgctxt "base.model.Sequence.fields" msgid "Leave empty for a global sequence shared by all companies." msgstr "留空表示所有公司共享的全局序列。" -#: service/models/sequence.ts:93 +#: service/models/sequence.ts:96 msgctxt "base.model.Sequence.fields" msgid "Literal text appended to the generated number." msgstr "添加到生成号码后的固定文本。" -#: service/models/sequence.ts:83 +#: service/models/sequence.ts:86 msgctxt "base.model.Sequence.fields" msgid "Literal text prepended to the generated number." msgstr "添加到生成号码前的固定文本。" -#: service/models/sequence.ts:41 +#: service/models/sequence.ts:44 msgctxt "base.model.Sequence.fields" msgid "Name" msgstr "名称" -#: service/models/sequence.ts:115 +#: service/models/sequence.ts:118 msgctxt "base.model.Sequence.fields" msgid "Next Number" msgstr "下一个编号" -#: service/models/sequence.ts:116 +#: service/models/sequence.ts:119 msgctxt "base.model.Sequence.fields" msgid "Next issued number; lowering it may reuse already-assigned values." msgstr "下一个将发出的号码;调低可能导致已分配号码被重用。" -#: service/models/sequence.ts:103 +#: service/models/sequence.ts:106 msgctxt "base.model.Sequence.fields" msgid "Padding Length" msgstr "补零长度" -#: service/models/sequence.ts:82 +#: service/models/sequence.ts:85 msgctxt "base.model.Sequence.fields" msgid "Prefix" msgstr "前缀" -#: service/models/sequence.ts:52 +#: service/models/sequence.ts:55 msgctxt "base.model.Sequence.fields" msgid "Stable id passed to Sequence.Next; unique per company or globally." msgstr "传给 Sequence.Next 的稳定标识;按公司或全局唯一。" -#: service/models/sequence.ts:92 +#: service/models/sequence.ts:95 msgctxt "base.model.Sequence.fields" msgid "Suffix" msgstr "后缀" -#: service/models/sequence.ts:104 +#: service/models/sequence.ts:107 msgctxt "base.model.Sequence.fields" msgid "Zero-padded numeric width between prefix and suffix." msgstr "前缀与后缀之间数字部分的零填充宽度。" @@ -516,67 +516,67 @@ msgctxt "base.model.SequenceIdempotency.fields" msgid "Start" msgstr "起始值" -#: service/models/state.ts:47 +#: service/models/state.ts:48 msgctxt "base.model.State.fields" msgid "Active" msgstr "启用" -#: service/models/state.ts:27 +#: service/models/state.ts:28 msgctxt "base.model.State.fields" msgid "Code" msgstr "编码" -#: service/models/state.ts:38 +#: service/models/state.ts:39 msgctxt "base.model.State.fields" msgid "Country" msgstr "国家/地区" -#: service/models/state.ts:18 +#: service/models/state.ts:19 msgctxt "base.model.State.fields" msgid "Name" msgstr "名称" -#: service/models/uom.ts:84 +#: service/models/uom.ts:96 msgctxt "base.model.UoM.fields" msgid "Active" msgstr "启用" -#: service/models/uom.ts:37 +#: service/models/uom.ts:49 msgctxt "base.model.UoM.fields" msgid "Category" msgstr "类别" -#: service/models/uom.ts:66 +#: service/models/uom.ts:78 msgctxt "base.model.UoM.fields" msgid "Conversion Factor" msgstr "换算系数" -#: service/models/uom.ts:67 +#: service/models/uom.ts:79 msgctxt "base.model.UoM.fields" msgid "Multiplier to convert this unit to the category reference unit." msgstr "换算到该类别基准单位的乘数。" -#: service/models/uom.ts:19 +#: service/models/uom.ts:31 msgctxt "base.model.UoM.fields" msgid "Name" msgstr "名称" -#: service/models/uom.ts:46 +#: service/models/uom.ts:58 msgctxt "base.model.UoM.fields" msgid "One reference unit per category; its factor must be 1." msgstr "每个类别仅一个基准单位;其换算因子必须为 1。" -#: service/models/uom.ts:45 +#: service/models/uom.ts:57 msgctxt "base.model.UoM.fields" msgid "Reference Unit" msgstr "基准单位" -#: service/models/uom.ts:75 +#: service/models/uom.ts:87 msgctxt "base.model.UoM.fields" msgid "Rounding" msgstr "舍入精度" -#: service/models/uom.ts:26 +#: service/models/uom.ts:38 msgctxt "base.model.UoM.fields" msgid "Symbol" msgstr "符号" @@ -611,47 +611,47 @@ msgctxt "service/models/_base_bridge" msgid "Name is required" msgstr "名称为必填项" -#: service/models/_currency_convert.ts:103 service/models/_currency_convert.ts:106 +#: service/models/_currency_convert.ts:120 service/models/_currency_convert.ts:125 msgctxt "service/models/_currency_convert" msgid "Company not found" msgstr "未找到公司" -#: service/models/_currency_convert.ts:110 +#: service/models/_currency_convert.ts:134 msgctxt "service/models/_currency_convert" msgid "Company.CurrencyId is required" msgstr "Company.CurrencyId 为必填项" -#: service/models/_currency_convert.ts:71 +#: service/models/_currency_convert.ts:89 msgctxt "service/models/_currency_convert" msgid "CompanyId/FromCurrencyId/ToCurrencyId are required" msgstr "CompanyId、FromCurrencyId 和 ToCurrencyId 为必填项" -#: service/models/_currency_convert.ts:80 +#: service/models/_currency_convert.ts:97 msgctxt "service/models/_currency_convert" msgid "Date is invalid" msgstr "日期无效" -#: service/models/_currency_convert.ts:79 +#: service/models/_currency_convert.ts:96 msgctxt "service/models/_currency_convert" msgid "Date is required" msgstr "日期为必填项" -#: service/models/_currency_convert.ts:81 +#: service/models/_currency_convert.ts:98 msgctxt "service/models/_currency_convert" msgid "Date must be YYYY-MM-DD" msgstr "日期格式必须为 YYYY-MM-DD" -#: service/models/_currency_convert.ts:133 service/models/_currency_convert.ts:152 +#: service/models/_currency_convert.ts:159 service/models/_currency_convert.ts:180 msgctxt "service/models/_currency_convert" msgid "ExchangeRate not found for currency %s" msgstr "未找到币种 %s 对应的汇率" -#: service/models/_currency_convert.ts:86 +#: service/models/_currency_convert.ts:103 msgctxt "service/models/_currency_convert" msgid "Invalid Amount" msgstr "金额无效" -#: service/models/_currency_convert.ts:174 +#: service/models/_currency_convert.ts:204 msgctxt "service/models/_currency_convert" msgid "Target currency %s not found" msgstr "未找到目标币种 %s" @@ -696,56 +696,76 @@ msgctxt "service/models/_sequence_cleanup" msgid "OlderThan must be YYYY-MM-DD" msgstr "OlderThan 格式必须为 YYYY-MM-DD" -#: service/models/_sequence_next.ts:23 +#: service/models/_sequence_next.ts:32 msgctxt "service/models/_sequence_next" msgid "Count must be an integer >= 1" msgstr "Count 必须为大于或等于 1 的整数" -#: service/models/_sequence_next.ts:26 +#: service/models/_sequence_next.ts:35 msgctxt "service/models/_sequence_next" msgid "Count must be within 1..1000" msgstr "Count 必须在 1 到 1000 之间" -#: service/models/_sequence_next.ts:74 +#: service/models/_sequence_next.ts:94 msgctxt "service/models/_sequence_next" msgid "IdempotencyKey conflict with different request" msgstr "IdempotencyKey 与其他请求冲突" -#: service/models/_sequence_next.ts:37 +#: service/models/_sequence_next.ts:46 msgctxt "service/models/_sequence_next" msgid "IdempotencyKey is invalid" msgstr "IdempotencyKey 无效" -#: service/models/_sequence_next.ts:36 +#: service/models/_sequence_next.ts:45 msgctxt "service/models/_sequence_next" msgid "IdempotencyKey is too long" msgstr "IdempotencyKey 过长" -#: service/models/_sequence_next.ts:35 +#: service/models/_sequence_next.ts:44 msgctxt "service/models/_sequence_next" msgid "IdempotencyKey must be non-empty" msgstr "IdempotencyKey 不能为空" -#: service/models/_sequence_next.ts:152 +#: service/models/_sequence_next.ts:192 msgctxt "service/models/_sequence_next" msgid "Sequence allocation conflicted; please retry" msgstr "序列号分配发生冲突,请重试" -#: service/models/_sequence_next.ts:172 +#: service/models/_sequence_next.ts:208 msgctxt "service/models/_sequence_next" msgid "Sequence is inactive" msgstr "序列未启用" -#: service/models/_sequence_next.ts:130 service/models/_sequence_next.ts:197 +#: service/models/_sequence_next.ts:168 service/models/_sequence_next.ts:232 msgctxt "service/models/_sequence_next" msgid "Sequence not found" msgstr "未找到序列" -#: service/models/_sequence_next.ts:118 +#: service/models/_sequence_next.ts:156 msgctxt "service/models/_sequence_next" msgid "Sequence not found for Code=%s" msgstr "未找到 Code=%s 的序列" +#: service/models/_uom_convert.ts:56 +msgctxt "service/models/_uom_convert" +msgid "%s not found" +msgstr "未找到 %s" + +#: service/models/_uom_convert.ts:51 +msgctxt "service/models/_uom_convert" +msgid "Invalid Amount" +msgstr "金额无效" + +#: service/models/_uom_convert.ts:71 +msgctxt "service/models/_uom_convert" +msgid "UoM factor must be greater than 0" +msgstr "计量单位换算系数必须大于 0" + +#: service/models/_uom_convert.ts:65 +msgctxt "service/models/_uom_convert" +msgid "UoMs must belong to the same category" +msgstr "计量单位必须属于同一类别" + #: service/models/address.ts:115 msgctxt "service/models/address" msgid "City not found" @@ -786,52 +806,52 @@ msgctxt "service/models/address" msgid "Zip is required for this country" msgstr "此国家/地区要求填写邮政编码" -#: service/models/city.ts:87 +#: service/models/city.ts:93 msgctxt "service/models/city" msgid "City Code must be unique within Country + State" msgstr "城市编码在同一国家/地区与州/省下必须唯一" -#: service/models/city.ts:67 +#: service/models/city.ts:68 msgctxt "service/models/city" msgid "State not found" msgstr "未找到州/省" -#: service/models/city.ts:68 +#: service/models/city.ts:69 msgctxt "service/models/city" msgid "State.CountryId must equal City.CountryId" msgstr "State.CountryId 必须与 City.CountryId 相同" -#: service/models/company.ts:114 +#: service/models/company.ts:115 msgctxt "service/models/company" msgid "%s is required" msgstr "%s 为必填项" -#: service/models/company.ts:129 +#: service/models/company.ts:128 msgctxt "service/models/company" msgid "Company Code must be unique" msgstr "公司编码必须唯一" -#: service/models/company.ts:138 +#: service/models/company.ts:137 msgctxt "service/models/company" msgid "CurrencyId is required" msgstr "CurrencyId 为必填项" -#: service/models/company.ts:149 +#: service/models/company.ts:148 msgctxt "service/models/company" msgid "Invalid IANA timezone: %s" msgstr "无效的 IANA 时区:%s" -#: service/models/company.ts:172 +#: service/models/company.ts:171 msgctxt "service/models/company" msgid "ParentId cannot be a descendant of the company" msgstr "ParentId 不能是该公司的下级公司" -#: service/models/company.ts:159 +#: service/models/company.ts:158 msgctxt "service/models/company" msgid "ParentId cannot be self" msgstr "ParentId 不能指向自身" -#: service/models/company.ts:146 +#: service/models/company.ts:145 msgctxt "service/models/company" msgid "Timezone is required" msgstr "时区为必填项" @@ -841,27 +861,27 @@ msgctxt "service/models/country" msgid "AddressFormat contains unsupported token: %s" msgstr "AddressFormat 包含不支持的标记:%s" -#: service/models/currency.ts:100 +#: service/models/currency.ts:110 msgctxt "service/models/currency" msgid "DecimalDigits is required" msgstr "DecimalDigits 为必填项" -#: service/models/currency.ts:101 +#: service/models/currency.ts:111 msgctxt "service/models/currency" msgid "DecimalDigits must be a non-negative integer" msgstr "DecimalDigits 必须为非负整数" -#: service/models/currency.ts:108 +#: service/models/currency.ts:118 msgctxt "service/models/currency" msgid "Rounding must be a valid decimal" msgstr "Rounding 必须为有效的小数" -#: service/models/currency.ts:107 +#: service/models/currency.ts:117 msgctxt "service/models/currency" msgid "Rounding must be greater than 0" msgstr "Rounding 必须大于 0" -#: service/models/exchange_rate.ts:106 +#: service/models/exchange_rate.ts:109 msgctxt "service/models/exchange_rate" msgid "%s is required" msgstr "%s 为必填项" @@ -881,42 +901,42 @@ msgctxt "service/models/exchange_rate" msgid "Date must be YYYY-MM-DD" msgstr "日期格式必须为 YYYY-MM-DD" -#: service/models/exchange_rate.ts:123 +#: service/models/exchange_rate.ts:124 msgctxt "service/models/exchange_rate" msgid "ExchangeRate must be unique for CompanyId + CurrencyId + Date" msgstr "ExchangeRate 在相同 CompanyId、CurrencyId 和 Date 下必须唯一" -#: service/models/exchange_rate.ts:133 +#: service/models/exchange_rate.ts:134 msgctxt "service/models/exchange_rate" msgid "Rate must be a valid decimal" msgstr "Rate 必须为有效的小数" -#: service/models/exchange_rate.ts:132 +#: service/models/exchange_rate.ts:133 msgctxt "service/models/exchange_rate" msgid "Rate must be greater than 0" msgstr "Rate 必须大于 0" -#: service/models/language.ts:256 +#: service/models/language.ts:259 msgctxt "service/models/language" msgid "At least one language must stay active" msgstr "至少需要保留一门启用的语言" -#: service/models/language.ts:199 +#: service/models/language.ts:200 msgctxt "service/models/language" msgid "Code or LanguageId is required" msgstr "必须提供 Code 或 LanguageId" -#: service/models/language.ts:270 +#: service/models/language.ts:273 msgctxt "service/models/language" msgid "Language code cannot be changed" msgstr "语言代码不可修改" -#: service/models/language.ts:220 +#: service/models/language.ts:221 msgctxt "service/models/language" msgid "Language not found" msgstr "未找到语言" -#: service/models/language.ts:273 +#: service/models/language.ts:276 msgctxt "service/models/language" msgid "The root language en_US cannot be deactivated" msgstr "根语言 en_US 不可停用" @@ -951,42 +971,42 @@ msgctxt "service/models/sequence_idempotency" msgid "SequenceId is required" msgstr "SequenceId 为必填项" -#: service/models/state.ts:67 +#: service/models/state.ts:72 msgctxt "service/models/state" msgid "State Code must be unique within Country" msgstr "州/省编码在同一国家/地区内必须唯一" -#: service/models/uom.ts:102 +#: service/models/uom.ts:114 msgctxt "service/models/uom" msgid "Each UoM category can only have one reference unit" msgstr "每个计量单位类别只能有一个基准单位" -#: service/models/uom.ts:108 +#: service/models/uom.ts:120 msgctxt "service/models/uom" msgid "Each UoM category must have one reference unit" msgstr "每个计量单位类别必须有一个基准单位" -#: service/models/uom.ts:122 +#: service/models/uom.ts:141 msgctxt "service/models/uom" msgid "Factor must be a valid decimal" msgstr "Factor 必须为有效的小数" -#: service/models/uom.ts:121 +#: service/models/uom.ts:140 msgctxt "service/models/uom" msgid "Factor must be greater than 0" msgstr "Factor 必须大于 0" -#: service/models/uom.ts:125 +#: service/models/uom.ts:144 msgctxt "service/models/uom" msgid "Reference UoM must have Factor = 1" msgstr "基准计量单位的 Factor 必须等于 1" -#: service/models/uom.ts:134 +#: service/models/uom.ts:153 msgctxt "service/models/uom" msgid "Rounding must be a valid decimal" msgstr "Rounding 必须为有效的小数" -#: service/models/uom.ts:133 +#: service/models/uom.ts:152 msgctxt "service/models/uom" msgid "Rounding must be greater than 0" msgstr "Rounding 必须大于 0" diff --git a/modules/base/service/models/_uom_convert.ts b/modules/base/service/models/_uom_convert.ts index d082cf581..6f76d55ac 100644 --- a/modules/base/service/models/_uom_convert.ts +++ b/modules/base/service/models/_uom_convert.ts @@ -52,12 +52,7 @@ export async function convertUoM(UoMModel: UoMOps, params: UoMConvertParams | un ); const load = async (id: string, label: string): Promise => { - let row: UoM | null | undefined; - try { - row = await UoMModel.Browse(id, UOM_FIELDS); - } catch { - notFound(_t('%s not found', { scope: 'service/models/_uom_convert' }, label)); - } + const row = await UoMModel.Browse(id, UOM_FIELDS); if (!row) notFound(_t('%s not found', { scope: 'service/models/_uom_convert' }, label)); return row as UoMRow; }; diff --git a/modules/base/service/tests/uom_convert.test.ts b/modules/base/service/tests/uom_convert.test.ts index f086f25b7..21fea81bf 100644 --- a/modules/base/service/tests/uom_convert.test.ts +++ b/modules/base/service/tests/uom_convert.test.ts @@ -54,6 +54,19 @@ test('base.UoM Convert scales by factor within one category and rounds to the ta expect(back.Amount.eq(new Decimal('0.02'))).toBe(true); }); +test('base.UoM Convert reports NotFound when a UoM id is missing', async () => { + const categoryId = await createCategory(); + const gramId = await createUnit(categoryId, '1', true); + let error: unknown; + try { + await UoM.Convert({ Amount: '1', FromUoMId: gramId, ToUoMId: 'missing_uom_id' }); + } catch (err) { + error = err; + } + expect(error instanceof ChoysumError).toBe(true); + expect((error as ChoysumError).code).toBe('NotFound'); +}); + test('base.UoM Convert rejects units from different categories', async () => { const weightId = await createCategory(); const lengthId = await createCategory(); diff --git a/modules/document/i18n/document.pot b/modules/document/i18n/document.pot index abf0ac866..cb59e51f4 100644 --- a/modules/document/i18n/document.pot +++ b/modules/document/i18n/document.pot @@ -52,237 +52,237 @@ msgctxt "document.model.AttachmentBinding.fields" msgid "Unbound At" msgstr "" -#: service/models/attachment_object.ts:85 +#: service/models/attachment_object.ts:86 msgctxt "document.model.AttachmentContent.fields" msgid "Checksum SHA-256" msgstr "" -#: service/models/attachment_object.ts:156 +#: service/models/attachment_object.ts:157 msgctxt "document.model.AttachmentContent.fields" msgid "Company" msgstr "" -#: service/models/attachment_object.ts:116 +#: service/models/attachment_object.ts:117 msgctxt "document.model.AttachmentContent.fields" msgid "Image Format" msgstr "" -#: service/models/attachment_object.ts:105 +#: service/models/attachment_object.ts:106 msgctxt "document.model.AttachmentContent.fields" msgid "Image Height" msgstr "" -#: service/models/attachment_object.ts:95 +#: service/models/attachment_object.ts:96 msgctxt "document.model.AttachmentContent.fields" msgid "Image Width" msgstr "" -#: service/models/attachment_object.ts:73 +#: service/models/attachment_object.ts:74 msgctxt "document.model.AttachmentContent.fields" msgid "MIME Type" msgstr "" -#: service/models/attachment_object.ts:125 +#: service/models/attachment_object.ts:126 msgctxt "document.model.AttachmentContent.fields" msgid "Metadata" msgstr "" -#: service/models/attachment_object.ts:61 +#: service/models/attachment_object.ts:62 msgctxt "document.model.AttachmentContent.fields" msgid "Size Bytes" msgstr "" -#: service/models/attachment_object.ts:143 +#: service/models/attachment_object.ts:144 msgctxt "document.model.AttachmentContent.fields" msgid "Status" msgstr "" -#: service/models/attachment_object.ts:50 +#: service/models/attachment_object.ts:51 msgctxt "document.model.AttachmentContent.fields" msgid "Stored Content" msgstr "" -#: service/models/attachment_mutation_ledger.ts:33 +#: service/models/attachment_mutation_ledger.ts:35 msgctxt "document.model.AttachmentMutationLedger.fields" msgid "Action" msgstr "" -#: service/models/attachment_mutation_ledger.ts:105 +#: service/models/attachment_mutation_ledger.ts:107 msgctxt "document.model.AttachmentMutationLedger.fields" msgid "Company" msgstr "" -#: service/models/attachment_mutation_ledger.ts:92 +#: service/models/attachment_mutation_ledger.ts:94 msgctxt "document.model.AttachmentMutationLedger.fields" msgid "Error Code" msgstr "" -#: service/models/attachment_mutation_ledger.ts:46 +#: service/models/attachment_mutation_ledger.ts:48 msgctxt "document.model.AttachmentMutationLedger.fields" msgid "Mutation" msgstr "" -#: service/models/attachment_mutation_ledger.ts:55 +#: service/models/attachment_mutation_ledger.ts:57 msgctxt "document.model.AttachmentMutationLedger.fields" msgid "Request" msgstr "" -#: service/models/attachment_mutation_ledger.ts:64 +#: service/models/attachment_mutation_ledger.ts:66 msgctxt "document.model.AttachmentMutationLedger.fields" msgid "Response" msgstr "" -#: service/models/attachment_mutation_ledger.ts:81 +#: service/models/attachment_mutation_ledger.ts:83 msgctxt "document.model.AttachmentMutationLedger.fields" msgid "Status" msgstr "" -#: service/models/upload_session.ts:208 +#: service/models/upload_session.ts:210 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Allowed MIME Types" msgstr "" -#: service/models/upload_session.ts:253 +#: service/models/upload_session.ts:255 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Attachment Content" msgstr "" -#: service/models/upload_session.ts:95 +#: service/models/upload_session.ts:97 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Business Request" msgstr "" -#: service/models/upload_session.ts:135 +#: service/models/upload_session.ts:137 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Checksum SHA-256" msgstr "" -#: service/models/upload_session.ts:267 +#: service/models/upload_session.ts:269 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Company" msgstr "" -#: service/models/upload_session.ts:220 +#: service/models/upload_session.ts:222 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Expires At" msgstr "" -#: service/models/upload_session.ts:51 +#: service/models/upload_session.ts:53 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Field Name" msgstr "" -#: service/models/upload_session.ts:81 +#: service/models/upload_session.ts:83 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Issuer User" msgstr "" -#: service/models/upload_session.ts:186 +#: service/models/upload_session.ts:188 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Max Upload Bytes" msgstr "" -#: service/models/upload_session.ts:67 +#: service/models/upload_session.ts:69 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Operation" msgstr "" -#: service/models/upload_session.ts:28 +#: service/models/upload_session.ts:30 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Owner Model" msgstr "" -#: service/models/upload_session.ts:39 +#: service/models/upload_session.ts:41 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Owner Record" msgstr "" -#: service/models/upload_session.ts:115 +#: service/models/upload_session.ts:117 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Proposed Content Type" msgstr "" -#: service/models/upload_session.ts:105 +#: service/models/upload_session.ts:107 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Proposed File Name" msgstr "" -#: service/models/upload_session.ts:124 +#: service/models/upload_session.ts:126 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Proposed Size Bytes" msgstr "" -#: service/models/upload_session.ts:199 +#: service/models/upload_session.ts:201 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Required Checksum Algorithm" msgstr "" -#: service/models/upload_session.ts:239 +#: service/models/upload_session.ts:241 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Status" msgstr "" -#: service/models/upload_session.ts:155 +#: service/models/upload_session.ts:157 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Uploaded Checksum SHA-256" msgstr "" -#: service/models/upload_session.ts:165 +#: service/models/upload_session.ts:167 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Uploaded Content Type" msgstr "" -#: service/models/upload_session.ts:176 +#: service/models/upload_session.ts:178 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Uploaded Payload Ref" msgstr "" -#: service/models/upload_session.ts:144 +#: service/models/upload_session.ts:146 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Uploaded Size Bytes" msgstr "" -#: service/models/stored_content.ts:52 +#: service/models/stored_content.ts:53 msgctxt "document.model.StoredContent.fields" msgid "Blob Data" msgstr "" -#: service/models/stored_content.ts:82 +#: service/models/stored_content.ts:83 msgctxt "document.model.StoredContent.fields" msgid "Company" msgstr "" -#: service/models/stored_content.ts:42 +#: service/models/stored_content.ts:43 msgctxt "document.model.StoredContent.fields" msgid "Locator JSON" msgstr "" -#: service/models/stored_content.ts:32 +#: service/models/stored_content.ts:33 msgctxt "document.model.StoredContent.fields" msgid "Provider" msgstr "" -#: service/models/stored_content.ts:69 +#: service/models/stored_content.ts:70 msgctxt "document.model.StoredContent.fields" msgid "Status" msgstr "" -#: service/models/_attachment_binding_codec.ts:283 +#: service/models/_attachment_binding_codec.ts:289 msgctxt "service/models/_attachment_binding_codec" msgid "Attachment resource company scope mismatch" msgstr "" -#: service/models/_attachment_binding_codec.ts:104 +#: service/models/_attachment_binding_codec.ts:109 msgctxt "service/models/_attachment_binding_codec" msgid "attachmentBindingIds exceeds maximum batch size of %s" msgstr "" -#: service/models/_attachment_binding_codec.ts:92 +#: service/models/_attachment_binding_codec.ts:97 msgctxt "service/models/_attachment_binding_codec" msgid "attachmentBindingIds must be an array" msgstr "" -#: service/models/_attachment_binding_codec.ts:146 +#: service/models/_attachment_binding_codec.ts:150 msgctxt "service/models/_attachment_binding_codec" msgid "downloadDisposition must be inline or attachment" msgstr "" @@ -337,236 +337,230 @@ msgctxt "service/models/_document_bridge" msgid "activeCompanyId is required for document operations" msgstr "" -#: service/models/_document_bridge.ts:98 +#: service/models/_document_bridge.ts:132 +msgctxt "service/models/_document_bridge" +msgid "principal is derived from the session" +msgstr "" + +#: service/models/_document_bridge.ts:88 msgctxt "service/models/_document_bridge" msgid "principal.enabledCompanyIds must be an array" msgstr "" -#: service/models/_owner_authorization.ts:295 +#: service/models/_owner_authorization.ts:299 msgctxt "service/models/_owner_authorization" msgid "%s is required" msgstr "" -#: service/models/_owner_authorization.ts:202 +#: service/models/_owner_authorization.ts:205 msgctxt "service/models/_owner_authorization" msgid "auth service is unavailable for owner authorization check" msgstr "" -#: service/models/_owner_authorization.ts:191 +#: service/models/_owner_authorization.ts:194 msgctxt "service/models/_owner_authorization" msgid "failed to fetch owner field rule spec" msgstr "" -#: service/models/_owner_authorization.ts:177 +#: service/models/_owner_authorization.ts:180 msgctxt "service/models/_owner_authorization" msgid "failed to fetch owner record rule condition" msgstr "" -#: service/models/_owner_authorization.ts:148 +#: service/models/_owner_authorization.ts:151 msgctxt "service/models/_owner_authorization" msgid "owner field read is denied by field rule" msgstr "" -#: service/models/_owner_authorization.ts:88 +#: service/models/_owner_authorization.ts:91 msgctxt "service/models/_owner_authorization" msgid "owner field write is denied by field rule" msgstr "" -#: service/models/_owner_authorization.ts:136 +#: service/models/_owner_authorization.ts:139 msgctxt "service/models/_owner_authorization" msgid "owner read is denied by record rule" msgstr "" -#: service/models/_owner_authorization.ts:160 +#: service/models/_owner_authorization.ts:163 msgctxt "service/models/_owner_authorization" msgid "owner read target is not allowed by record rule scope" msgstr "" -#: service/models/_owner_authorization.ts:262 +#: service/models/_owner_authorization.ts:266 msgctxt "service/models/_owner_authorization" msgid "owner record rule contains invalid token mapping" msgstr "" -#: service/models/_owner_authorization.ts:218 +#: service/models/_owner_authorization.ts:221 msgctxt "service/models/_owner_authorization" msgid "owner target is not readable" msgstr "" -#: service/models/_owner_authorization.ts:77 +#: service/models/_owner_authorization.ts:80 msgctxt "service/models/_owner_authorization" msgid "owner write is denied by record rule" msgstr "" -#: service/models/_owner_authorization.ts:110 +#: service/models/_owner_authorization.ts:113 msgctxt "service/models/_owner_authorization" msgid "owner write target is not allowed by record rule scope" msgstr "" -#: service/models/_owner_authorization.ts:98 +#: service/models/_owner_authorization.ts:101 msgctxt "service/models/_owner_authorization" msgid "ownerRecordId is required for owner write check" msgstr "" #: service/models/_upload.ts:75 -#: service/models/_upload.ts:84 msgctxt "service/models/_upload" msgid "%s is required" msgstr "" -#: service/models/_upload.ts:94 +#: service/models/_upload.ts:82 msgctxt "service/models/_upload" msgid "%s must be a non-negative integer" msgstr "" -#: service/models/_upload.ts:211 +#: service/models/_upload.ts:196 msgctxt "service/models/_upload" msgid "activeCompanyId does not match upload session owner" msgstr "" -#: service/models/_upload.ts:266 +#: service/models/_upload.ts:251 msgctxt "service/models/_upload" msgid "businessRequestId was already used with a different upload context" msgstr "" -#: service/models/_upload.ts:135 +#: service/models/_upload.ts:122 msgctxt "service/models/_upload" msgid "operation must be create or update" msgstr "" -#: service/models/_upload.ts:145 +#: service/models/_upload.ts:132 msgctxt "service/models/_upload" msgid "ownerRecordId is required when operation=update" msgstr "" -#: service/models/_upload.ts:112 +#: service/models/_upload.ts:99 msgctxt "service/models/_upload" msgid "payloadReceipt.payloadId must be an opaque handle, inline byte payload is forbidden" msgstr "" -#: service/models/_upload.ts:223 +#: service/models/_upload.ts:208 msgctxt "service/models/_upload" msgid "userId does not match upload session issuer" msgstr "" -#: service/models/attachment_binding.ts:421 +#: service/models/attachment_binding.ts:393 msgctxt "service/models/attachment_binding" msgid "Active attachment binding not found" msgstr "" -#: service/models/attachment_binding.ts:285 +#: service/models/attachment_binding.ts:257 msgctxt "service/models/attachment_binding" msgid "Active attachment content not found" msgstr "" -#: service/models/attachment_binding.ts:257 +#: service/models/attachment_binding.ts:229 msgctxt "service/models/attachment_binding" msgid "Active attachment content not found in company scope" msgstr "" -#: service/models/attachment_binding.ts:309 +#: service/models/attachment_binding.ts:281 msgctxt "service/models/attachment_binding" msgid "Active stored content not found" msgstr "" -#: service/models/attachment_binding.ts:397 +#: service/models/attachment_binding.ts:369 msgctxt "service/models/attachment_binding" msgid "Attachment binding not found" msgstr "" -#: service/models/attachment_binding.ts:479 +#: service/models/attachment_binding.ts:451 msgctxt "service/models/attachment_binding" msgid "bind mutationId exists but previous attempt did not succeed" msgstr "" -#: service/models/attachment_binding.ts:493 +#: service/models/attachment_binding.ts:465 msgctxt "service/models/attachment_binding" msgid "bind replay snapshot is invalid" msgstr "" -#: service/models/attachment_binding.ts:343 +#: service/models/attachment_binding.ts:315 msgctxt "service/models/attachment_binding" msgid "database execute bridge is unavailable" msgstr "" -#: service/models/attachment_binding.ts:230 -msgctxt "service/models/attachment_binding" -msgid "principal activeCompanyId does not match runtime context" -msgstr "" - -#: service/models/attachment_binding.ts:217 -msgctxt "service/models/attachment_binding" -msgid "principal userId does not match runtime identity" -msgstr "" - -#: service/models/attachment_binding.ts:508 +#: service/models/attachment_binding.ts:480 msgctxt "service/models/attachment_binding" msgid "unbind mutationId exists but previous attempt did not succeed" msgstr "" -#: service/models/attachment_binding.ts:522 +#: service/models/attachment_binding.ts:494 msgctxt "service/models/attachment_binding" msgid "unbind replay snapshot is invalid" msgstr "" -#: service/models/attachment_object.ts:331 +#: service/models/attachment_object.ts:340 msgctxt "service/models/attachment_object" msgid "Attachment content not found" msgstr "" -#: service/models/attachment_object.ts:377 +#: service/models/attachment_object.ts:386 msgctxt "service/models/attachment_object" msgid "FinalizeUpload businessRequestId does not match the existing upload session" msgstr "" -#: service/models/attachment_object.ts:299 +#: service/models/attachment_object.ts:304 msgctxt "service/models/attachment_object" msgid "Stored content company does not match upload session company" msgstr "" -#: service/models/attachment_object.ts:313 +#: service/models/attachment_object.ts:318 msgctxt "service/models/attachment_object" msgid "Stored content must be active before finalize" msgstr "" -#: service/models/attachment_object.ts:283 +#: service/models/attachment_object.ts:288 msgctxt "service/models/attachment_object" msgid "Upload session is missing uploaded payload reference" msgstr "" -#: service/models/attachment_object.ts:488 +#: service/models/attachment_object.ts:513 msgctxt "service/models/attachment_object" msgid "Upload session must be prepared before commit" msgstr "" -#: service/models/attachment_object.ts:623 +#: service/models/attachment_object.ts:648 msgctxt "service/models/attachment_object" msgid "Upload session must be uploaded before finalize" msgstr "" -#: service/models/attachment_object.ts:251 +#: service/models/attachment_object.ts:256 msgctxt "service/models/attachment_object" msgid "Upload session not found" msgstr "" -#: service/models/attachment_object.ts:446 -#: service/models/attachment_object.ts:528 +#: service/models/attachment_object.ts:463 +#: service/models/attachment_object.ts:553 msgctxt "service/models/attachment_object" msgid "checksum mismatch" msgstr "" -#: service/models/attachment_object.ts:433 -#: service/models/attachment_object.ts:515 +#: service/models/attachment_object.ts:450 +#: service/models/attachment_object.ts:540 msgctxt "service/models/attachment_object" msgid "content type is not allowed" msgstr "" -#: service/models/attachment_object.ts:419 -#: service/models/attachment_object.ts:501 +#: service/models/attachment_object.ts:436 +#: service/models/attachment_object.ts:526 msgctxt "service/models/attachment_object" msgid "upload body exceeds maxUploadBytes" msgstr "" -#: service/models/stored_content.ts:92 +#: service/models/stored_content.ts:97 msgctxt "service/models/stored_content" msgid "Stored content not found" msgstr "" diff --git a/modules/document/i18n/zh_CN.po b/modules/document/i18n/zh_CN.po index a52ddd3c8..30d7b6626 100644 --- a/modules/document/i18n/zh_CN.po +++ b/modules/document/i18n/zh_CN.po @@ -46,237 +46,237 @@ msgctxt "document.model.AttachmentBinding.fields" msgid "Unbound At" msgstr "解绑时间" -#: service/models/attachment_object.ts:85 +#: service/models/attachment_object.ts:86 msgctxt "document.model.AttachmentContent.fields" msgid "Checksum SHA-256" msgstr "SHA-256 校验和" -#: service/models/attachment_object.ts:156 +#: service/models/attachment_object.ts:157 msgctxt "document.model.AttachmentContent.fields" msgid "Company" msgstr "公司" -#: service/models/attachment_object.ts:116 +#: service/models/attachment_object.ts:117 msgctxt "document.model.AttachmentContent.fields" msgid "Image Format" msgstr "图片格式" -#: service/models/attachment_object.ts:105 +#: service/models/attachment_object.ts:106 msgctxt "document.model.AttachmentContent.fields" msgid "Image Height" msgstr "图片高度" -#: service/models/attachment_object.ts:95 +#: service/models/attachment_object.ts:96 msgctxt "document.model.AttachmentContent.fields" msgid "Image Width" msgstr "图片宽度" -#: service/models/attachment_object.ts:73 +#: service/models/attachment_object.ts:74 msgctxt "document.model.AttachmentContent.fields" msgid "MIME Type" msgstr "MIME 类型" -#: service/models/attachment_object.ts:125 +#: service/models/attachment_object.ts:126 msgctxt "document.model.AttachmentContent.fields" msgid "Metadata" msgstr "元数据" -#: service/models/attachment_object.ts:61 +#: service/models/attachment_object.ts:62 msgctxt "document.model.AttachmentContent.fields" msgid "Size Bytes" msgstr "字节大小" -#: service/models/attachment_object.ts:143 +#: service/models/attachment_object.ts:144 msgctxt "document.model.AttachmentContent.fields" msgid "Status" msgstr "状态" -#: service/models/attachment_object.ts:50 +#: service/models/attachment_object.ts:51 msgctxt "document.model.AttachmentContent.fields" msgid "Stored Content" msgstr "存储内容" -#: service/models/attachment_mutation_ledger.ts:33 +#: service/models/attachment_mutation_ledger.ts:35 msgctxt "document.model.AttachmentMutationLedger.fields" msgid "Action" msgstr "动作" -#: service/models/attachment_mutation_ledger.ts:105 +#: service/models/attachment_mutation_ledger.ts:107 msgctxt "document.model.AttachmentMutationLedger.fields" msgid "Company" msgstr "公司" -#: service/models/attachment_mutation_ledger.ts:92 +#: service/models/attachment_mutation_ledger.ts:94 msgctxt "document.model.AttachmentMutationLedger.fields" msgid "Error Code" msgstr "错误码" -#: service/models/attachment_mutation_ledger.ts:46 +#: service/models/attachment_mutation_ledger.ts:48 msgctxt "document.model.AttachmentMutationLedger.fields" msgid "Mutation" msgstr "变更" -#: service/models/attachment_mutation_ledger.ts:55 +#: service/models/attachment_mutation_ledger.ts:57 msgctxt "document.model.AttachmentMutationLedger.fields" msgid "Request" msgstr "请求" -#: service/models/attachment_mutation_ledger.ts:64 +#: service/models/attachment_mutation_ledger.ts:66 msgctxt "document.model.AttachmentMutationLedger.fields" msgid "Response" msgstr "响应" -#: service/models/attachment_mutation_ledger.ts:81 +#: service/models/attachment_mutation_ledger.ts:83 msgctxt "document.model.AttachmentMutationLedger.fields" msgid "Status" msgstr "状态" -#: service/models/upload_session.ts:208 +#: service/models/upload_session.ts:210 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Allowed MIME Types" msgstr "允许的 MIME 类型" -#: service/models/upload_session.ts:253 +#: service/models/upload_session.ts:255 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Attachment Content" msgstr "附件内容" -#: service/models/upload_session.ts:95 +#: service/models/upload_session.ts:97 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Business Request" msgstr "业务请求" -#: service/models/upload_session.ts:135 +#: service/models/upload_session.ts:137 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Checksum SHA-256" msgstr "SHA-256 校验和" -#: service/models/upload_session.ts:267 +#: service/models/upload_session.ts:269 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Company" msgstr "公司" -#: service/models/upload_session.ts:220 +#: service/models/upload_session.ts:222 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Expires At" msgstr "过期时间" -#: service/models/upload_session.ts:51 +#: service/models/upload_session.ts:53 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Field Name" msgstr "字段名" -#: service/models/upload_session.ts:81 +#: service/models/upload_session.ts:83 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Issuer User" msgstr "签发用户" -#: service/models/upload_session.ts:186 +#: service/models/upload_session.ts:188 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Max Upload Bytes" msgstr "最大上传字节" -#: service/models/upload_session.ts:67 +#: service/models/upload_session.ts:69 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Operation" msgstr "操作" -#: service/models/upload_session.ts:28 +#: service/models/upload_session.ts:30 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Owner Model" msgstr "所属模型" -#: service/models/upload_session.ts:39 +#: service/models/upload_session.ts:41 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Owner Record" msgstr "所属记录" -#: service/models/upload_session.ts:115 +#: service/models/upload_session.ts:117 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Proposed Content Type" msgstr "拟议内容类型" -#: service/models/upload_session.ts:105 +#: service/models/upload_session.ts:107 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Proposed File Name" msgstr "拟议文件名" -#: service/models/upload_session.ts:124 +#: service/models/upload_session.ts:126 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Proposed Size Bytes" msgstr "拟议字节大小" -#: service/models/upload_session.ts:199 +#: service/models/upload_session.ts:201 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Required Checksum Algorithm" msgstr "要求的校验算法" -#: service/models/upload_session.ts:239 +#: service/models/upload_session.ts:241 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Status" msgstr "状态" -#: service/models/upload_session.ts:155 +#: service/models/upload_session.ts:157 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Uploaded Checksum SHA-256" msgstr "已上传 SHA-256 校验和" -#: service/models/upload_session.ts:165 +#: service/models/upload_session.ts:167 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Uploaded Content Type" msgstr "已上传内容类型" -#: service/models/upload_session.ts:176 +#: service/models/upload_session.ts:178 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Uploaded Payload Ref" msgstr "已上传载荷引用" -#: service/models/upload_session.ts:144 +#: service/models/upload_session.ts:146 msgctxt "document.model.AttachmentUploadSession.fields" msgid "Uploaded Size Bytes" msgstr "已上传字节大小" -#: service/models/stored_content.ts:52 +#: service/models/stored_content.ts:53 msgctxt "document.model.StoredContent.fields" msgid "Blob Data" msgstr "二进制数据" -#: service/models/stored_content.ts:82 +#: service/models/stored_content.ts:83 msgctxt "document.model.StoredContent.fields" msgid "Company" msgstr "公司" -#: service/models/stored_content.ts:42 +#: service/models/stored_content.ts:43 msgctxt "document.model.StoredContent.fields" msgid "Locator JSON" msgstr "定位器 JSON" -#: service/models/stored_content.ts:32 +#: service/models/stored_content.ts:33 msgctxt "document.model.StoredContent.fields" msgid "Provider" msgstr "提供者" -#: service/models/stored_content.ts:69 +#: service/models/stored_content.ts:70 msgctxt "document.model.StoredContent.fields" msgid "Status" msgstr "状态" -#: service/models/_attachment_binding_codec.ts:283 +#: service/models/_attachment_binding_codec.ts:289 msgctxt "service/models/_attachment_binding_codec" msgid "Attachment resource company scope mismatch" msgstr "附件资源的公司范围不匹配" -#: service/models/_attachment_binding_codec.ts:104 +#: service/models/_attachment_binding_codec.ts:109 msgctxt "service/models/_attachment_binding_codec" msgid "attachmentBindingIds exceeds maximum batch size of %s" msgstr "attachmentBindingIds 超出最大批量大小 %s" -#: service/models/_attachment_binding_codec.ts:92 +#: service/models/_attachment_binding_codec.ts:97 msgctxt "service/models/_attachment_binding_codec" msgid "attachmentBindingIds must be an array" msgstr "attachmentBindingIds 必须是数组" -#: service/models/_attachment_binding_codec.ts:146 +#: service/models/_attachment_binding_codec.ts:150 msgctxt "service/models/_attachment_binding_codec" msgid "downloadDisposition must be inline or attachment" msgstr "downloadDisposition 必须是 inline 或 attachment" @@ -331,232 +331,227 @@ msgctxt "service/models/_document_bridge" msgid "activeCompanyId is required for document operations" msgstr "文档操作需要 activeCompanyId" -#: service/models/_document_bridge.ts:98 +#: service/models/_document_bridge.ts:132 +msgctxt "service/models/_document_bridge" +msgid "principal is derived from the session" +msgstr "主体身份由会话派生" + +#: service/models/_document_bridge.ts:88 msgctxt "service/models/_document_bridge" msgid "principal.enabledCompanyIds must be an array" msgstr "principal.enabledCompanyIds 必须是数组" -#: service/models/_owner_authorization.ts:295 +#: service/models/_owner_authorization.ts:299 msgctxt "service/models/_owner_authorization" msgid "%s is required" msgstr "%s 为必填项" -#: service/models/_owner_authorization.ts:202 +#: service/models/_owner_authorization.ts:205 msgctxt "service/models/_owner_authorization" msgid "auth service is unavailable for owner authorization check" msgstr "所有者授权检查时认证服务不可用" -#: service/models/_owner_authorization.ts:191 +#: service/models/_owner_authorization.ts:194 msgctxt "service/models/_owner_authorization" msgid "failed to fetch owner field rule spec" msgstr "获取所有者字段规则规格失败" -#: service/models/_owner_authorization.ts:177 +#: service/models/_owner_authorization.ts:180 msgctxt "service/models/_owner_authorization" msgid "failed to fetch owner record rule condition" msgstr "获取所有者记录规则条件失败" -#: service/models/_owner_authorization.ts:148 +#: service/models/_owner_authorization.ts:151 msgctxt "service/models/_owner_authorization" msgid "owner field read is denied by field rule" msgstr "所有者字段读取被字段规则拒绝" -#: service/models/_owner_authorization.ts:88 +#: service/models/_owner_authorization.ts:91 msgctxt "service/models/_owner_authorization" msgid "owner field write is denied by field rule" msgstr "所有者字段写入被字段规则拒绝" -#: service/models/_owner_authorization.ts:136 +#: service/models/_owner_authorization.ts:139 msgctxt "service/models/_owner_authorization" msgid "owner read is denied by record rule" msgstr "所有者读取被记录规则拒绝" -#: service/models/_owner_authorization.ts:160 +#: service/models/_owner_authorization.ts:163 msgctxt "service/models/_owner_authorization" msgid "owner read target is not allowed by record rule scope" msgstr "所有者读取目标不在记录规则范围内" -#: service/models/_owner_authorization.ts:262 +#: service/models/_owner_authorization.ts:266 msgctxt "service/models/_owner_authorization" msgid "owner record rule contains invalid token mapping" msgstr "所有者记录规则包含无效的令牌映射" -#: service/models/_owner_authorization.ts:218 +#: service/models/_owner_authorization.ts:221 msgctxt "service/models/_owner_authorization" msgid "owner target is not readable" msgstr "所有者目标不可读" -#: service/models/_owner_authorization.ts:77 +#: service/models/_owner_authorization.ts:80 msgctxt "service/models/_owner_authorization" msgid "owner write is denied by record rule" msgstr "所有者写入被记录规则拒绝" -#: service/models/_owner_authorization.ts:110 +#: service/models/_owner_authorization.ts:113 msgctxt "service/models/_owner_authorization" msgid "owner write target is not allowed by record rule scope" msgstr "所有者写入目标不在记录规则范围内" -#: service/models/_owner_authorization.ts:98 +#: service/models/_owner_authorization.ts:101 msgctxt "service/models/_owner_authorization" msgid "ownerRecordId is required for owner write check" msgstr "所有者写入检查需要 ownerRecordId" -#: service/models/_upload.ts:75 service/models/_upload.ts:84 +#: service/models/_upload.ts:75 msgctxt "service/models/_upload" msgid "%s is required" msgstr "%s 为必填项" -#: service/models/_upload.ts:94 +#: service/models/_upload.ts:82 msgctxt "service/models/_upload" msgid "%s must be a non-negative integer" msgstr "%s 必须为非负整数" -#: service/models/_upload.ts:211 +#: service/models/_upload.ts:196 msgctxt "service/models/_upload" msgid "activeCompanyId does not match upload session owner" msgstr "activeCompanyId 与上传会话所有者不匹配" -#: service/models/_upload.ts:266 +#: service/models/_upload.ts:251 msgctxt "service/models/_upload" msgid "businessRequestId was already used with a different upload context" msgstr "businessRequestId 已在不同的上传上下文中使用过" -#: service/models/_upload.ts:135 +#: service/models/_upload.ts:122 msgctxt "service/models/_upload" msgid "operation must be create or update" msgstr "operation 必须是 create 或 update" -#: service/models/_upload.ts:145 +#: service/models/_upload.ts:132 msgctxt "service/models/_upload" msgid "ownerRecordId is required when operation=update" msgstr "当 operation=update 时 ownerRecordId 为必填项" -#: service/models/_upload.ts:112 +#: service/models/_upload.ts:99 msgctxt "service/models/_upload" msgid "payloadReceipt.payloadId must be an opaque handle, inline byte payload is forbidden" msgstr "payloadReceipt.payloadId 必须是不透明句柄,禁止内联字节载荷" -#: service/models/_upload.ts:223 +#: service/models/_upload.ts:208 msgctxt "service/models/_upload" msgid "userId does not match upload session issuer" msgstr "userId 与上传会话签发者不匹配" -#: service/models/attachment_binding.ts:421 +#: service/models/attachment_binding.ts:393 msgctxt "service/models/attachment_binding" msgid "Active attachment binding not found" msgstr "未找到有效的附件绑定" -#: service/models/attachment_binding.ts:285 +#: service/models/attachment_binding.ts:257 msgctxt "service/models/attachment_binding" msgid "Active attachment content not found" msgstr "未找到有效的附件内容" -#: service/models/attachment_binding.ts:257 +#: service/models/attachment_binding.ts:229 msgctxt "service/models/attachment_binding" msgid "Active attachment content not found in company scope" msgstr "在公司范围内未找到有效的附件内容" -#: service/models/attachment_binding.ts:309 +#: service/models/attachment_binding.ts:281 msgctxt "service/models/attachment_binding" msgid "Active stored content not found" msgstr "未找到有效的存储内容" -#: service/models/attachment_binding.ts:397 +#: service/models/attachment_binding.ts:369 msgctxt "service/models/attachment_binding" msgid "Attachment binding not found" msgstr "未找到附件绑定" -#: service/models/attachment_binding.ts:479 +#: service/models/attachment_binding.ts:451 msgctxt "service/models/attachment_binding" msgid "bind mutationId exists but previous attempt did not succeed" msgstr "bind 的 mutationId 已存在,但先前尝试未成功" -#: service/models/attachment_binding.ts:493 +#: service/models/attachment_binding.ts:465 msgctxt "service/models/attachment_binding" msgid "bind replay snapshot is invalid" msgstr "bind 回放快照无效" -#: service/models/attachment_binding.ts:343 +#: service/models/attachment_binding.ts:315 msgctxt "service/models/attachment_binding" msgid "database execute bridge is unavailable" msgstr "数据库 execute 桥接不可用" -#: service/models/attachment_binding.ts:230 -msgctxt "service/models/attachment_binding" -msgid "principal activeCompanyId does not match runtime context" -msgstr "principal 的 activeCompanyId 与运行时上下文不匹配" - -#: service/models/attachment_binding.ts:217 -msgctxt "service/models/attachment_binding" -msgid "principal userId does not match runtime identity" -msgstr "principal 的 userId 与运行时身份不匹配" - -#: service/models/attachment_binding.ts:508 +#: service/models/attachment_binding.ts:480 msgctxt "service/models/attachment_binding" msgid "unbind mutationId exists but previous attempt did not succeed" msgstr "unbind 的 mutationId 已存在,但先前尝试未成功" -#: service/models/attachment_binding.ts:522 +#: service/models/attachment_binding.ts:494 msgctxt "service/models/attachment_binding" msgid "unbind replay snapshot is invalid" msgstr "unbind 回放快照无效" -#: service/models/attachment_object.ts:331 +#: service/models/attachment_object.ts:340 msgctxt "service/models/attachment_object" msgid "Attachment content not found" msgstr "未找到附件内容" -#: service/models/attachment_object.ts:377 +#: service/models/attachment_object.ts:386 msgctxt "service/models/attachment_object" msgid "FinalizeUpload businessRequestId does not match the existing upload session" msgstr "FinalizeUpload 的 businessRequestId 与现有上传会话不匹配" -#: service/models/attachment_object.ts:299 +#: service/models/attachment_object.ts:304 msgctxt "service/models/attachment_object" msgid "Stored content company does not match upload session company" msgstr "存储内容的公司与上传会话的公司不匹配" -#: service/models/attachment_object.ts:313 +#: service/models/attachment_object.ts:318 msgctxt "service/models/attachment_object" msgid "Stored content must be active before finalize" msgstr "完成前存储内容必须处于 active 状态" -#: service/models/attachment_object.ts:283 +#: service/models/attachment_object.ts:288 msgctxt "service/models/attachment_object" msgid "Upload session is missing uploaded payload reference" msgstr "上传会话缺少已上传载荷引用" -#: service/models/attachment_object.ts:488 +#: service/models/attachment_object.ts:513 msgctxt "service/models/attachment_object" msgid "Upload session must be prepared before commit" msgstr "提交前上传会话必须处于 prepared 状态" -#: service/models/attachment_object.ts:623 +#: service/models/attachment_object.ts:648 msgctxt "service/models/attachment_object" msgid "Upload session must be uploaded before finalize" msgstr "完成前上传会话必须处于 uploaded 状态" -#: service/models/attachment_object.ts:251 +#: service/models/attachment_object.ts:256 msgctxt "service/models/attachment_object" msgid "Upload session not found" msgstr "未找到上传会话" -#: service/models/attachment_object.ts:446 service/models/attachment_object.ts:528 +#: service/models/attachment_object.ts:463 service/models/attachment_object.ts:553 msgctxt "service/models/attachment_object" msgid "checksum mismatch" msgstr "校验和不匹配" -#: service/models/attachment_object.ts:433 service/models/attachment_object.ts:515 +#: service/models/attachment_object.ts:450 service/models/attachment_object.ts:540 msgctxt "service/models/attachment_object" msgid "content type is not allowed" msgstr "不允许该内容类型" -#: service/models/attachment_object.ts:419 service/models/attachment_object.ts:501 +#: service/models/attachment_object.ts:436 service/models/attachment_object.ts:526 msgctxt "service/models/attachment_object" msgid "upload body exceeds maxUploadBytes" msgstr "上传正文超过 maxUploadBytes" -#: service/models/stored_content.ts:92 +#: service/models/stored_content.ts:97 msgctxt "service/models/stored_content" msgid "Stored content not found" msgstr "未找到存储内容" @@ -664,3 +659,11 @@ msgstr "未找到存储内容" #~ msgctxt "service/models/_attachment_upload_ops" #~ msgid "upload body exceeds maxUploadBytes" #~ msgstr "上传正文超过 maxUploadBytes" + +#~ msgctxt "service/models/attachment_binding" +#~ msgid "principal activeCompanyId does not match runtime context" +#~ msgstr "principal 的 activeCompanyId 与运行时上下文不匹配" + +#~ msgctxt "service/models/attachment_binding" +#~ msgid "principal userId does not match runtime identity" +#~ msgstr "principal 的 userId 与运行时身份不匹配" diff --git a/modules/partner/i18n/partner.pot b/modules/partner/i18n/partner.pot index fb9a2f0ee..0a73a5d3f 100644 --- a/modules/partner/i18n/partner.pot +++ b/modules/partner/i18n/partner.pot @@ -7,129 +7,129 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: service/models/partner.ts:81 +#: service/models/partner.ts:98 msgctxt "partner.model.Partner.fields" msgid "Active" msgstr "" -#: service/models/partner.ts:55 +#: service/models/partner.ts:72 msgctxt "partner.model.Partner.fields" msgid "Code" msgstr "" -#: service/models/partner.ts:71 +#: service/models/partner.ts:88 msgctxt "partner.model.Partner.fields" msgid "Company" msgstr "" -#: service/models/partner.ts:207 -#: service/models/partner.ts:226 -#: service/models/partner.ts:245 +#: service/models/partner.ts:224 +#: service/models/partner.ts:243 +#: service/models/partner.ts:262 msgctxt "partner.model.Partner.fields" msgid "Computed from contacts; edit contacts to change defaults." msgstr "" -#: service/models/partner.ts:197 +#: service/models/partner.ts:214 msgctxt "partner.model.Partner.fields" msgid "Contacts and Addresses" msgstr "" -#: service/models/partner.ts:153 +#: service/models/partner.ts:170 msgctxt "partner.model.Partner.fields" msgid "Country" msgstr "" -#: service/models/partner.ts:104 +#: service/models/partner.ts:121 msgctxt "partner.model.Partner.fields" msgid "Customer Rank" msgstr "" -#: service/models/partner.ts:225 +#: service/models/partner.ts:242 msgctxt "partner.model.Partner.fields" msgid "Default Billing Address" msgstr "" -#: service/models/partner.ts:206 +#: service/models/partner.ts:223 msgctxt "partner.model.Partner.fields" msgid "Default Contact" msgstr "" -#: service/models/partner.ts:142 +#: service/models/partner.ts:159 msgctxt "partner.model.Partner.fields" msgid "Default Currency" msgstr "" -#: service/models/partner.ts:131 +#: service/models/partner.ts:148 msgctxt "partner.model.Partner.fields" msgid "Default Language" msgstr "" -#: service/models/partner.ts:244 +#: service/models/partner.ts:261 msgctxt "partner.model.Partner.fields" msgid "Default Shipping Address" msgstr "" -#: service/models/partner.ts:171 +#: service/models/partner.ts:188 msgctxt "partner.model.Partner.fields" msgid "Email" msgstr "" -#: service/models/partner.ts:162 +#: service/models/partner.ts:179 msgctxt "partner.model.Partner.fields" msgid "External Reference" msgstr "" -#: service/models/partner.ts:189 +#: service/models/partner.ts:206 msgctxt "partner.model.Partner.fields" msgid "Mobile" msgstr "" -#: service/models/partner.ts:44 +#: service/models/partner.ts:61 msgctxt "partner.model.Partner.fields" msgid "Name" msgstr "" -#: service/models/partner.ts:105 +#: service/models/partner.ts:122 msgctxt "partner.model.Partner.fields" msgid "Non-zero marks the partner as a customer; higher ranks sort first." msgstr "" -#: service/models/partner.ts:118 +#: service/models/partner.ts:135 msgctxt "partner.model.Partner.fields" msgid "Non-zero marks the partner as a supplier; higher ranks sort first." msgstr "" -#: service/models/partner.ts:273 +#: service/models/partner.ts:290 msgctxt "partner.model.Partner.fields" msgid "Notes" msgstr "" -#: service/models/partner.ts:91 +#: service/models/partner.ts:108 msgctxt "partner.model.Partner.fields" msgid "Organization" msgstr "" -#: service/models/partner.ts:180 +#: service/models/partner.ts:197 msgctxt "partner.model.Partner.fields" msgid "Phone" msgstr "" -#: service/models/partner.ts:264 +#: service/models/partner.ts:281 msgctxt "partner.model.Partner.fields" msgid "Sequence" msgstr "" -#: service/models/partner.ts:117 +#: service/models/partner.ts:134 msgctxt "partner.model.Partner.fields" msgid "Supplier Rank" msgstr "" -#: service/models/partner.ts:92 +#: service/models/partner.ts:109 msgctxt "partner.model.Partner.fields" msgid "Uncheck for an individual person rather than an organization." msgstr "" -#: service/models/partner.ts:56 +#: service/models/partner.ts:73 msgctxt "partner.model.Partner.fields" msgid "Unique within the company; stored in uppercase." msgstr "" @@ -224,12 +224,18 @@ msgctxt "partner.model.PartnerContact.fields" msgid "billing/shipping/office/registered/other; drives partner default addresses." msgstr "" -#: service/models/partner.ts:321 +#: service/models/partner.ts:338 +#: service/models/partner.ts:37 msgctxt "service/models/partner" msgid "CompanyId is required" msgstr "" -#: service/models/partner.ts:333 +#: service/models/partner.ts:425 +msgctxt "service/models/partner" +msgid "CompanyId must match the active company" +msgstr "" + +#: service/models/partner.ts:350 msgctxt "service/models/partner" msgid "Partner Code must be unique within the company" msgstr "" @@ -239,7 +245,7 @@ msgctxt "service/models/partner_contact" msgid "AddressType must be one of billing, shipping, office, registered, other" msgstr "" -#: service/models/partner_contact.ts:285 +#: service/models/partner_contact.ts:288 msgctxt "service/models/partner_contact" msgid "CompanyId is required" msgstr "" @@ -255,7 +261,7 @@ msgid "PartnerContact requires at least Name, AddressId, Email, Phone, or Mobile msgstr "" #: service/models/partner_contact.ts:203 -#: service/models/partner_contact.ts:284 +#: service/models/partner_contact.ts:287 msgctxt "service/models/partner_contact" msgid "PartnerId is required" msgstr "" @@ -340,6 +346,16 @@ msgctxt "web/views/PartnerFormView" msgid "Contacts and Addresses" msgstr "" +#: web/views/partner_actions.ts:17 +msgctxt "web/views/PartnerFormView" +msgid "Copy Partner" +msgstr "" + +#: web/views/partner_actions.ts:17 +msgctxt "web/views/PartnerFormView" +msgid "Create Partner" +msgstr "" + #: web/views/PartnerFormView.vue:121 msgctxt "web/views/PartnerFormView" msgid "Default" @@ -355,6 +371,11 @@ msgctxt "web/views/PartnerFormView" msgid "Delete" msgstr "" +#: web/views/partner_actions.ts:17 +msgctxt "web/views/PartnerFormView" +msgid "Delete Partner" +msgstr "" + #: web/views/PartnerFormView.vue:130 msgctxt "web/views/PartnerFormView" msgid "Edit" @@ -365,6 +386,11 @@ msgctxt "web/views/PartnerFormView" msgid "Edit Contact" msgstr "" +#: web/views/partner_actions.ts:17 +msgctxt "web/views/PartnerFormView" +msgid "Edit Partner" +msgstr "" + #: web/views/PartnerFormView.vue:127 msgctxt "web/views/PartnerFormView" msgid "Email" @@ -405,6 +431,11 @@ msgctxt "web/views/PartnerFormView" msgid "Other" msgstr "" +#: web/views/partner_actions.ts:18 +msgctxt "web/views/PartnerFormView" +msgid "Partner" +msgstr "" + #: web/views/PartnerFormView.vue:128 msgctxt "web/views/PartnerFormView" msgid "Phone" @@ -480,3 +511,8 @@ msgctxt "web/views/PartnerFormView" msgid "View Contact" msgstr "" +#: web/views/partner_actions.ts:12 +msgctxt "web/views/PartnerListView" +msgid "Open Partner Detail" +msgstr "" + diff --git a/modules/partner/i18n/zh_CN.po b/modules/partner/i18n/zh_CN.po index 7ca8cebb1..1be3f5fc9 100644 --- a/modules/partner/i18n/zh_CN.po +++ b/modules/partner/i18n/zh_CN.po @@ -1,262 +1,267 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\nLanguage: zh_CN\n" -#: service/models/partner.ts:77 +#: service/models/partner.ts:98 msgctxt "partner.model.Partner.fields" msgid "Active" msgstr "启用" -#: service/models/partner.ts:51 +#: service/models/partner.ts:72 msgctxt "partner.model.Partner.fields" msgid "Code" msgstr "编码" -#: service/models/partner.ts:67 +#: service/models/partner.ts:88 msgctxt "partner.model.Partner.fields" msgid "Company" msgstr "所属公司" -#: service/models/partner.ts:203 service/models/partner.ts:222 service/models/partner.ts:241 +#: service/models/partner.ts:224 service/models/partner.ts:243 service/models/partner.ts:262 msgctxt "partner.model.Partner.fields" msgid "Computed from contacts; edit contacts to change defaults." msgstr "由联系人计算得出;请通过编辑联系人更改默认值。" -#: service/models/partner.ts:193 +#: service/models/partner.ts:214 msgctxt "partner.model.Partner.fields" msgid "Contacts and Addresses" msgstr "联系人与地址" -#: service/models/partner.ts:149 +#: service/models/partner.ts:170 msgctxt "partner.model.Partner.fields" msgid "Country" msgstr "国家" -#: service/models/partner.ts:100 +#: service/models/partner.ts:121 msgctxt "partner.model.Partner.fields" msgid "Customer Rank" msgstr "客户等级" -#: service/models/partner.ts:221 +#: service/models/partner.ts:242 msgctxt "partner.model.Partner.fields" msgid "Default Billing Address" msgstr "默认开票地址" -#: service/models/partner.ts:202 +#: service/models/partner.ts:223 msgctxt "partner.model.Partner.fields" msgid "Default Contact" msgstr "默认联系人" -#: service/models/partner.ts:138 +#: service/models/partner.ts:159 msgctxt "partner.model.Partner.fields" msgid "Default Currency" msgstr "默认币种" -#: service/models/partner.ts:127 +#: service/models/partner.ts:148 msgctxt "partner.model.Partner.fields" msgid "Default Language" msgstr "默认语言" -#: service/models/partner.ts:240 +#: service/models/partner.ts:261 msgctxt "partner.model.Partner.fields" msgid "Default Shipping Address" msgstr "默认收货地址" -#: service/models/partner.ts:167 +#: service/models/partner.ts:188 msgctxt "partner.model.Partner.fields" msgid "Email" msgstr "邮箱" -#: service/models/partner.ts:158 +#: service/models/partner.ts:179 msgctxt "partner.model.Partner.fields" msgid "External Reference" msgstr "外部参考" -#: service/models/partner.ts:185 +#: service/models/partner.ts:206 msgctxt "partner.model.Partner.fields" msgid "Mobile" msgstr "手机" -#: service/models/partner.ts:40 +#: service/models/partner.ts:61 msgctxt "partner.model.Partner.fields" msgid "Name" msgstr "名称" -#: service/models/partner.ts:101 +#: service/models/partner.ts:122 msgctxt "partner.model.Partner.fields" msgid "Non-zero marks the partner as a customer; higher ranks sort first." msgstr "非零表示客户;数值越大排序越靠前。" -#: service/models/partner.ts:114 +#: service/models/partner.ts:135 msgctxt "partner.model.Partner.fields" msgid "Non-zero marks the partner as a supplier; higher ranks sort first." msgstr "非零表示供应商;数值越大排序越靠前。" -#: service/models/partner.ts:269 +#: service/models/partner.ts:290 msgctxt "partner.model.Partner.fields" msgid "Notes" msgstr "备注" -#: service/models/partner.ts:87 +#: service/models/partner.ts:108 msgctxt "partner.model.Partner.fields" msgid "Organization" msgstr "组织主体" -#: service/models/partner.ts:176 +#: service/models/partner.ts:197 msgctxt "partner.model.Partner.fields" msgid "Phone" msgstr "电话" -#: service/models/partner.ts:260 +#: service/models/partner.ts:281 msgctxt "partner.model.Partner.fields" msgid "Sequence" msgstr "排序" -#: service/models/partner.ts:113 +#: service/models/partner.ts:134 msgctxt "partner.model.Partner.fields" msgid "Supplier Rank" msgstr "供应商等级" -#: service/models/partner.ts:88 +#: service/models/partner.ts:109 msgctxt "partner.model.Partner.fields" msgid "Uncheck for an individual person rather than an organization." msgstr "取消勾选表示个人,而非组织。" -#: service/models/partner.ts:52 +#: service/models/partner.ts:73 msgctxt "partner.model.Partner.fields" msgid "Unique within the company; stored in uppercase." msgstr "在公司内唯一;存储为大写。" -#: service/models/partner_contact.ts:151 +#: service/models/partner_contact.ts:155 msgctxt "partner.model.PartnerContact.fields" msgid "Active" msgstr "启用" -#: service/models/partner_contact.ts:116 +#: service/models/partner_contact.ts:120 msgctxt "partner.model.PartnerContact.fields" msgid "Address" msgstr "地址" -#: service/models/partner_contact.ts:125 +#: service/models/partner_contact.ts:129 msgctxt "partner.model.PartnerContact.fields" msgid "Address Type" msgstr "地址类型" -#: service/models/partner_contact.ts:169 +#: service/models/partner_contact.ts:173 msgctxt "partner.model.PartnerContact.fields" msgid "Attention To" msgstr "收件人" -#: service/models/partner_contact.ts:41 +#: service/models/partner_contact.ts:45 msgctxt "partner.model.PartnerContact.fields" msgid "Company" msgstr "所属公司" -#: service/models/partner_contact.ts:138 +#: service/models/partner_contact.ts:142 msgctxt "partner.model.PartnerContact.fields" msgid "Default" msgstr "默认" -#: service/models/partner_contact.ts:97 +#: service/models/partner_contact.ts:101 msgctxt "partner.model.PartnerContact.fields" msgid "Department" msgstr "部门" -#: service/models/partner_contact.ts:59 +#: service/models/partner_contact.ts:63 msgctxt "partner.model.PartnerContact.fields" msgid "Email" msgstr "邮箱" -#: service/models/partner_contact.ts:77 +#: service/models/partner_contact.ts:81 msgctxt "partner.model.PartnerContact.fields" msgid "Mobile" msgstr "手机" -#: service/models/partner_contact.ts:50 +#: service/models/partner_contact.ts:54 msgctxt "partner.model.PartnerContact.fields" msgid "Name" msgstr "名称" -#: service/models/partner_contact.ts:178 +#: service/models/partner_contact.ts:182 msgctxt "partner.model.PartnerContact.fields" msgid "Notes" msgstr "备注" -#: service/models/partner_contact.ts:139 +#: service/models/partner_contact.ts:143 msgctxt "partner.model.PartnerContact.fields" msgid "One default contact per partner and address type; used by partner default pickers." msgstr "同一伙伴下每种地址类型仅一个默认联系人;用于伙伴默认联系人与地址选择。" -#: service/models/partner_contact.ts:30 +#: service/models/partner_contact.ts:34 msgctxt "partner.model.PartnerContact.fields" msgid "Partner" msgstr "伙伴" -#: service/models/partner_contact.ts:68 +#: service/models/partner_contact.ts:72 msgctxt "partner.model.PartnerContact.fields" msgid "Phone" msgstr "电话" -#: service/models/partner_contact.ts:106 +#: service/models/partner_contact.ts:110 msgctxt "partner.model.PartnerContact.fields" msgid "Role" msgstr "角色" -#: service/models/partner_contact.ts:161 +#: service/models/partner_contact.ts:165 msgctxt "partner.model.PartnerContact.fields" msgid "Sequence" msgstr "排序" -#: service/models/partner_contact.ts:87 +#: service/models/partner_contact.ts:91 msgctxt "partner.model.PartnerContact.fields" msgid "Title" msgstr "称谓" -#: service/models/partner_contact.ts:126 +#: service/models/partner_contact.ts:130 msgctxt "partner.model.PartnerContact.fields" msgid "billing/shipping/office/registered/other; drives partner default addresses." msgstr "billing/shipping/office/registered/other;驱动业务伙伴默认地址。" -#: service/models/partner.ts:317 +#: service/models/partner.ts:338 service/models/partner.ts:37 msgctxt "service/models/partner" msgid "CompanyId is required" msgstr "CompanyId 为必填项" -#: service/models/partner.ts:329 +#: service/models/partner.ts:425 +msgctxt "service/models/partner" +msgid "CompanyId must match the active company" +msgstr "CompanyId 必须与当前活动公司一致" + +#: service/models/partner.ts:350 msgctxt "service/models/partner" msgid "Partner Code must be unique within the company" msgstr "伙伴编码在同一公司内必须唯一" -#: service/models/partner_contact.ts:187 +#: service/models/partner_contact.ts:191 msgctxt "service/models/partner_contact" msgid "AddressType must be one of billing, shipping, office, registered, other" msgstr "AddressType 必须是 billing、shipping、office、registered 或 other" -#: service/models/partner_contact.ts:281 +#: service/models/partner_contact.ts:288 msgctxt "service/models/partner_contact" msgid "CompanyId is required" msgstr "CompanyId 为必填项" -#: service/models/partner_contact.ts:215 +#: service/models/partner_contact.ts:219 msgctxt "service/models/partner_contact" msgid "Only one default %s contact is allowed for the same partner" msgstr "同一伙伴下每个 %s 类型只能有一个默认联系人" -#: service/models/partner_contact.ts:227 +#: service/models/partner_contact.ts:231 msgctxt "service/models/partner_contact" msgid "PartnerContact requires at least Name, AddressId, Email, Phone, or Mobile" msgstr "联系人至少需要填写 Name、AddressId、Email、Phone 或 Mobile 之一" -#: service/models/partner_contact.ts:199 service/models/partner_contact.ts:280 +#: service/models/partner_contact.ts:203 service/models/partner_contact.ts:287 msgctxt "service/models/partner_contact" msgid "PartnerId is required" msgstr "PartnerId 为必填项" -#: web/menu/menus.ts:21 +#: web/menu/titles.ts:10 msgctxt "web/menu/menus" msgid "Partner List" msgstr "伙伴列表" -#: web/menu/menus.ts:16 +#: web/menu/titles.ts:9 msgctxt "web/menu/menus" msgid "Partner Management" msgstr "伙伴管理" @@ -306,12 +311,12 @@ msgctxt "web/views/PartnerFormView" msgid "Basic Information" msgstr "基础信息" -#: web/views/PartnerFormView.vue:86 +#: web/views/PartnerFormView.vue:85 msgctxt "web/views/PartnerFormView" msgid "Billing Address" msgstr "开票地址" -#: web/views/PartnerFormView.vue:108 +#: web/views/PartnerFormView.vue:107 msgctxt "web/views/PartnerFormView" msgid "Billing Contact" msgstr "财务联系人" @@ -331,12 +336,12 @@ msgctxt "web/views/PartnerFormView" msgid "Contacts and Addresses" msgstr "联系人与地址" -#: web/views/PartnerFormView.vue:57 +#: web/views/partner_actions.ts:17 msgctxt "web/views/PartnerFormView" msgid "Copy Partner" msgstr "复制伙伴" -#: web/views/PartnerFormView.vue:57 +#: web/views/partner_actions.ts:17 msgctxt "web/views/PartnerFormView" msgid "Create Partner" msgstr "新建伙伴" @@ -356,7 +361,7 @@ msgctxt "web/views/PartnerFormView" msgid "Delete" msgstr "删除" -#: web/views/PartnerFormView.vue:57 +#: web/views/partner_actions.ts:17 msgctxt "web/views/PartnerFormView" msgid "Delete Partner" msgstr "删除伙伴" @@ -371,7 +376,7 @@ msgctxt "web/views/PartnerFormView" msgid "Edit Contact" msgstr "编辑联系人" -#: web/views/PartnerFormView.vue:57 +#: web/views/partner_actions.ts:17 msgctxt "web/views/PartnerFormView" msgid "Edit Partner" msgstr "编辑伙伴" @@ -381,12 +386,12 @@ msgctxt "web/views/PartnerFormView" msgid "Email" msgstr "邮箱" -#: web/views/PartnerFormView.vue:116 +#: web/views/PartnerFormView.vue:115 msgctxt "web/views/PartnerFormView" msgid "Finance Contact" msgstr "财务负责人" -#: web/views/PartnerFormView.vue:106 +#: web/views/PartnerFormView.vue:105 msgctxt "web/views/PartnerFormView" msgid "General Contact" msgstr "普通联系人" @@ -396,7 +401,7 @@ msgctxt "web/views/PartnerFormView" msgid "Inactive" msgstr "停用" -#: web/views/PartnerFormView.vue:118 +#: web/views/PartnerFormView.vue:117 msgctxt "web/views/PartnerFormView" msgid "Legal Contact" msgstr "法务联系人" @@ -406,17 +411,17 @@ msgctxt "web/views/PartnerFormView" msgid "New Contact" msgstr "新增联系人" -#: web/views/PartnerFormView.vue:90 +#: web/views/PartnerFormView.vue:89 msgctxt "web/views/PartnerFormView" msgid "Office Address" msgstr "办公地址" -#: web/views/PartnerFormView.vue:94 +#: web/views/PartnerFormView.vue:93 msgctxt "web/views/PartnerFormView" msgid "Other" msgstr "其他" -#: web/views/PartnerFormView.vue:57 +#: web/views/partner_actions.ts:18 msgctxt "web/views/PartnerFormView" msgid "Partner" msgstr "伙伴" @@ -426,12 +431,12 @@ msgctxt "web/views/PartnerFormView" msgid "Phone" msgstr "电话" -#: web/views/PartnerFormView.vue:112 +#: web/views/PartnerFormView.vue:111 msgctxt "web/views/PartnerFormView" msgid "Procurement Contact" msgstr "采购联系人" -#: web/views/PartnerFormView.vue:92 +#: web/views/PartnerFormView.vue:91 msgctxt "web/views/PartnerFormView" msgid "Registered Address" msgstr "注册地址" @@ -451,7 +456,7 @@ msgctxt "web/views/PartnerFormView" msgid "Role" msgstr "角色" -#: web/views/PartnerFormView.vue:114 +#: web/views/PartnerFormView.vue:113 msgctxt "web/views/PartnerFormView" msgid "Sales Contact" msgstr "销售联系人" @@ -476,12 +481,12 @@ msgctxt "web/views/PartnerFormView" msgid "Select Language" msgstr "选择语言" -#: web/views/PartnerFormView.vue:88 +#: web/views/PartnerFormView.vue:87 msgctxt "web/views/PartnerFormView" msgid "Shipping Address" msgstr "收货地址" -#: web/views/PartnerFormView.vue:110 +#: web/views/PartnerFormView.vue:109 msgctxt "web/views/PartnerFormView" msgid "Shipping Contact" msgstr "收货联系人" @@ -496,36 +501,11 @@ msgctxt "web/views/PartnerFormView" msgid "View Contact" msgstr "查看联系人" -#: web/views/PartnerListView.vue:42 -msgctxt "web/views/PartnerListView" -msgid "Copy Partner" -msgstr "复制伙伴" - -#: web/views/PartnerListView.vue:42 -msgctxt "web/views/PartnerListView" -msgid "Create Partner" -msgstr "新建伙伴" - -#: web/views/PartnerListView.vue:42 -msgctxt "web/views/PartnerListView" -msgid "Delete Partner" -msgstr "删除伙伴" - -#: web/views/PartnerListView.vue:42 -msgctxt "web/views/PartnerListView" -msgid "Edit Partner" -msgstr "编辑伙伴" - -#: web/views/PartnerListView.vue:39 +#: web/views/partner_actions.ts:12 msgctxt "web/views/PartnerListView" msgid "Open Partner Detail" msgstr "打开伙伴详情" -#: web/views/PartnerListView.vue:42 -msgctxt "web/views/PartnerListView" -msgid "Partner" -msgstr "伙伴" - #~ msgctxt "web/components/PartnerImportWizard" #~ msgid "Cancel" #~ msgstr "取消" @@ -589,3 +569,23 @@ msgstr "伙伴" #~ msgctxt "web/pages/PartnerList" #~ msgid "Import CSV" #~ msgstr "导入 CSV" + +#~ msgctxt "web/views/PartnerListView" +#~ msgid "Copy Partner" +#~ msgstr "复制伙伴" + +#~ msgctxt "web/views/PartnerListView" +#~ msgid "Create Partner" +#~ msgstr "新建伙伴" + +#~ msgctxt "web/views/PartnerListView" +#~ msgid "Delete Partner" +#~ msgstr "删除伙伴" + +#~ msgctxt "web/views/PartnerListView" +#~ msgid "Edit Partner" +#~ msgstr "编辑伙伴" + +#~ msgctxt "web/views/PartnerListView" +#~ msgid "Partner" +#~ msgstr "伙伴" diff --git a/modules/partner/service/models/partner.ts b/modules/partner/service/models/partner.ts index 8154ab4aa..25ba3965d 100644 --- a/modules/partner/service/models/partner.ts +++ b/modules/partner/service/models/partner.ts @@ -6,6 +6,7 @@ import type { ModelCtor, RowOf } from '@/core/service'; import { Constraint } from '@/core/service/api/constraint'; import { getActiveCompanyId } from '@/core/service/api/context'; import type { FieldSelection, Insertable } from '@/core/service/api'; +import { resolveValidationSummary } from '@/core/service/api/validation'; import { normalizeRefId } from '@/core/service/utils/normalization'; import PartnerCollaborationModel from '../mixins/partner_collaboration_model'; import { _t, _lt } from '../i18n'; @@ -16,6 +17,15 @@ import type Currency from '@/base/service/models/currency'; import type Language from '@/base/service/models/language'; import PartnerContact from './partner_contact'; +function isCodeConflict(err: unknown): boolean { + if (resolveValidationSummary(err as { metadata?: Record }).sqlCode === 'sql_unique_violation') { + return true; + } + // Application constraint runs before SQL and uses the same uniqueness rule. + const message = String((err as { message?: unknown })?.message || ''); + return message.includes('Partner Code must be unique'); +} + export type PartnerFindOrCreateReq = { Code: string; /** Display name used only when a new partner is created. Defaults to Code. */ @@ -391,6 +401,7 @@ export default class Partner extends PartnerCollaborationModel { const created = await this.Create({ Name: name, Code: code, CompanyId: companyId } as Partial, ['Id'] as any); return { PartnerId: String((created as { Id?: unknown }).Id || ''), Created: true }; } catch (err) { + if (!isCodeConflict(err)) throw err; const again = await this.Search( { And: [['CompanyId', '=', companyId], ['Code', '=', code]] }, { fields: ['Id'], limit: 1 } @@ -404,6 +415,7 @@ export default class Partner extends PartnerCollaborationModel { /** * Create a partner from a display name (relation typeahead). * Code is derived from the name and disambiguated within the session company. + * Allocation retries on unique Code conflict rather than a pre-check Search loop. */ static async NameCreate> | undefined = undefined>( this: C, @@ -412,24 +424,32 @@ export default class Partner extends PartnerCollaborationModel { options?: { returnFields?: F } ): Promise { const partnerValues = (values || {}) as Partial>; - const companyId = String(normalizeRefId(partnerValues.CompanyId) || requireSessionCompanyId()); + const companyId = requireSessionCompanyId(); + const requestedCompanyId = normalizeRefId(partnerValues.CompanyId); + if (requestedCompanyId && requestedCompanyId !== companyId) { + fail(_t('CompanyId must match the active company', { scope: 'service/models/partner' })); + } const display = assertRequiredText(name, 'Name'); const seed = codeSeedFromName(display); - let code = String(partnerValues.Code || '').trim().toUpperCase() || seed.slice(0, 40); - if (!partnerValues.Code) { - for (let attempt = 0; attempt < 5; attempt++) { - const taken = await (this as unknown as typeof Partner).Search( - { And: [['CompanyId', '=', companyId], ['Code', '=', code]] }, - { fields: ['Id'], limit: 1 } - ); - if (!taken?.[0]?.Id) break; - const suffix = String(attempt + 1); + const explicitCode = String(partnerValues.Code || '').trim().toUpperCase(); + let code = explicitCode || seed.slice(0, 40); + const maxAttempts = explicitCode ? 1 : 32; + let lastErr: unknown; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (!explicitCode && attempt > 0) { + const suffix = String(attempt); code = `${seed.slice(0, Math.max(1, 40 - suffix.length))}${suffix}`; } + try { + return await (this as unknown as typeof Partner).Create( + { ...partnerValues, Name: display, Code: code, CompanyId: companyId } as Partial>, + (options?.returnFields as string[] | undefined) as any + ); + } catch (err) { + lastErr = err; + if (explicitCode || !isCodeConflict(err)) throw err; + } } - return (this as unknown as typeof Partner).Create( - { ...partnerValues, Name: display, Code: code, CompanyId: companyId } as Partial>, - (options?.returnFields as string[] | undefined) as any - ); + throw lastErr; } } diff --git a/modules/partner/service/tests/find_or_create.test.ts b/modules/partner/service/tests/find_or_create.test.ts index 19bbac3ca..688aab37e 100644 --- a/modules/partner/service/tests/find_or_create.test.ts +++ b/modules/partner/service/tests/find_or_create.test.ts @@ -59,3 +59,15 @@ test('partner.Partner NameCreate derives a company-unique code', async () => { expect(String((second as any).Id || '')).not.toBe(''); expect(String((second as any).Id)).not.toBe(String((first as any).Id)); }); + +test('partner.Partner NameCreate allocates beyond five collisions', async () => { + const companyId = await ensureCompanyId(); + const ids: string[] = []; + await withCompany(companyId, async () => { + for (let i = 0; i < 7; i++) { + const row = await Partner.NameCreate('Acme', undefined, { returnFields: ['Id', 'Code'] }); + ids.push(String((row as any).Id)); + } + }); + expect(new Set(ids).size).toBe(7); +}); diff --git a/modules/partner_commercial/i18n/partner_commercial.pot b/modules/partner_commercial/i18n/partner_commercial.pot index 01a032fb3..bdf849270 100644 --- a/modules/partner_commercial/i18n/partner_commercial.pot +++ b/modules/partner_commercial/i18n/partner_commercial.pot @@ -12,99 +12,105 @@ msgctxt "partner_commercial.model.Partner.fields" msgid "Identifiers" msgstr "" -#: service/models/partner_identifier.ts:98 +#: service/models/partner_identifier.ts:111 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Active" msgstr "" -#: service/models/partner_identifier.ts:36 +#: service/models/partner_identifier.ts:49 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Company" msgstr "" -#: service/models/partner_identifier.ts:75 +#: service/models/partner_identifier.ts:88 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Country" msgstr "" -#: service/models/partner_identifier.ts:108 +#: service/models/partner_identifier.ts:121 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Issued By" msgstr "" -#: service/models/partner_identifier.ts:48 +#: service/models/partner_identifier.ts:61 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Lowercase category (e.g. tax_id, vat); unique per partner and value." msgstr "" -#: service/models/partner_identifier.ts:133 +#: service/models/partner_identifier.ts:146 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Notes" msgstr "" -#: service/models/partner_identifier.ts:86 +#: service/models/partner_identifier.ts:99 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "One primary row allowed per partner and identifier type." msgstr "" -#: service/models/partner_identifier.ts:25 +#: service/models/partner_identifier.ts:38 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Partner" msgstr "" -#: service/models/partner_identifier.ts:85 +#: service/models/partner_identifier.ts:98 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Primary" msgstr "" -#: service/models/partner_identifier.ts:62 +#: service/models/partner_identifier.ts:75 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Stored in uppercase; duplicate type+value pairs for the same partner are rejected." msgstr "" -#: service/models/partner_identifier.ts:47 +#: service/models/partner_identifier.ts:60 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Type" msgstr "" -#: service/models/partner_identifier.ts:116 +#: service/models/partner_identifier.ts:129 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Valid From" msgstr "" -#: service/models/partner_identifier.ts:124 +#: service/models/partner_identifier.ts:137 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Valid To" msgstr "" -#: service/models/partner_identifier.ts:61 +#: service/models/partner_identifier.ts:74 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Value" msgstr "" -#: service/models/partner_identifier.ts:233 +#: service/models/partner_identifier.ts:301 +msgctxt "service/models/partner_identifier" +msgid "Ambiguous partner identifier match" +msgstr "" + +#: service/models/partner_identifier.ts:249 +#: service/models/partner_identifier.ts:287 msgctxt "service/models/partner_identifier" msgid "CompanyId is required" msgstr "" -#: service/models/partner_identifier.ts:182 +#: service/models/partner_identifier.ts:195 msgctxt "service/models/partner_identifier" msgid "Only one primary identifier is allowed per PartnerId + IdentifierType" msgstr "" -#: service/models/partner_identifier.ts:156 +#: service/models/partner_identifier.ts:169 msgctxt "service/models/partner_identifier" msgid "PartnerId + IdentifierType + Value must be unique" msgstr "" -#: service/models/partner_identifier.ts:143 -#: service/models/partner_identifier.ts:169 -#: service/models/partner_identifier.ts:232 +#: service/models/partner_identifier.ts:156 +#: service/models/partner_identifier.ts:182 +#: service/models/partner_identifier.ts:248 msgctxt "service/models/partner_identifier" msgid "PartnerId is required" msgstr "" -#: service/models/partner_identifier.ts:239 +#: service/models/partner_identifier.ts:257 msgctxt "service/models/partner_identifier" msgid "ValidFrom must be less than or equal to ValidTo" msgstr "" @@ -114,7 +120,7 @@ msgctxt "web/views/PartnerFormView" msgid "Add Identifier" msgstr "" -#: web/views/PartnerFormView.vue:33 +#: web/views/partner_identifier_actions.ts:10 msgctxt "web/views/PartnerFormView" msgid "Copy Identifier" msgstr "" @@ -124,7 +130,7 @@ msgctxt "web/views/PartnerFormView" msgid "Country" msgstr "" -#: web/views/PartnerFormView.vue:33 +#: web/views/partner_identifier_actions.ts:10 msgctxt "web/views/PartnerFormView" msgid "Create Identifier" msgstr "" @@ -134,7 +140,7 @@ msgctxt "web/views/PartnerFormView" msgid "Delete" msgstr "" -#: web/views/PartnerFormView.vue:33 +#: web/views/partner_identifier_actions.ts:10 msgctxt "web/views/PartnerFormView" msgid "Delete Identifier" msgstr "" @@ -144,13 +150,13 @@ msgctxt "web/views/PartnerFormView" msgid "Edit" msgstr "" -#: web/views/PartnerFormView.vue:33 #: web/views/PartnerFormView.vue:6 +#: web/views/partner_identifier_actions.ts:10 msgctxt "web/views/PartnerFormView" msgid "Edit Identifier" msgstr "" -#: web/views/PartnerFormView.vue:33 +#: web/views/partner_identifier_actions.ts:11 msgctxt "web/views/PartnerFormView" msgid "Identifier" msgstr "" diff --git a/modules/partner_commercial/i18n/zh_CN.po b/modules/partner_commercial/i18n/zh_CN.po index 801642fc3..24e85e722 100644 --- a/modules/partner_commercial/i18n/zh_CN.po +++ b/modules/partner_commercial/i18n/zh_CN.po @@ -6,97 +6,102 @@ msgctxt "partner_commercial.model.Partner.fields" msgid "Identifiers" msgstr "标识" -#: service/models/partner_identifier.ts:98 +#: service/models/partner_identifier.ts:111 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Active" msgstr "启用" -#: service/models/partner_identifier.ts:36 +#: service/models/partner_identifier.ts:49 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Company" msgstr "公司" -#: service/models/partner_identifier.ts:75 +#: service/models/partner_identifier.ts:88 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Country" msgstr "国家" -#: service/models/partner_identifier.ts:108 +#: service/models/partner_identifier.ts:121 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Issued By" msgstr "签发机构" -#: service/models/partner_identifier.ts:48 +#: service/models/partner_identifier.ts:61 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Lowercase category (e.g. tax_id, vat); unique per partner and value." msgstr "小写类别(如 tax_id、vat);按伙伴与值唯一。" -#: service/models/partner_identifier.ts:133 +#: service/models/partner_identifier.ts:146 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Notes" msgstr "备注" -#: service/models/partner_identifier.ts:86 +#: service/models/partner_identifier.ts:99 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "One primary row allowed per partner and identifier type." msgstr "每个伙伴与标识类型仅允许一条主记录。" -#: service/models/partner_identifier.ts:25 +#: service/models/partner_identifier.ts:38 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Partner" msgstr "业务伙伴" -#: service/models/partner_identifier.ts:85 +#: service/models/partner_identifier.ts:98 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Primary" msgstr "主标识" -#: service/models/partner_identifier.ts:62 +#: service/models/partner_identifier.ts:75 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Stored in uppercase; duplicate type+value pairs for the same partner are rejected." msgstr "以大写存储;同一伙伴下重复的类型+值组合会被拒绝。" -#: service/models/partner_identifier.ts:47 +#: service/models/partner_identifier.ts:60 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Type" msgstr "类型" -#: service/models/partner_identifier.ts:116 +#: service/models/partner_identifier.ts:129 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Valid From" msgstr "生效时间" -#: service/models/partner_identifier.ts:124 +#: service/models/partner_identifier.ts:137 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Valid To" msgstr "失效时间" -#: service/models/partner_identifier.ts:61 +#: service/models/partner_identifier.ts:74 msgctxt "partner_commercial.model.PartnerIdentifier.fields" msgid "Value" msgstr "标识值" -#: service/models/partner_identifier.ts:233 +#: service/models/partner_identifier.ts:301 +msgctxt "service/models/partner_identifier" +msgid "Ambiguous partner identifier match" +msgstr "合作伙伴标识匹配不唯一" + +#: service/models/partner_identifier.ts:249 service/models/partner_identifier.ts:287 msgctxt "service/models/partner_identifier" msgid "CompanyId is required" msgstr "CompanyId 为必填项" -#: service/models/partner_identifier.ts:182 +#: service/models/partner_identifier.ts:195 msgctxt "service/models/partner_identifier" msgid "Only one primary identifier is allowed per PartnerId + IdentifierType" msgstr "同一 PartnerId + IdentifierType 下只能有一个主标识" -#: service/models/partner_identifier.ts:156 +#: service/models/partner_identifier.ts:169 msgctxt "service/models/partner_identifier" msgid "PartnerId + IdentifierType + Value must be unique" msgstr "PartnerId + IdentifierType + Value 必须唯一" -#: service/models/partner_identifier.ts:143 service/models/partner_identifier.ts:169 service/models/partner_identifier.ts:232 +#: service/models/partner_identifier.ts:156 service/models/partner_identifier.ts:182 service/models/partner_identifier.ts:248 msgctxt "service/models/partner_identifier" msgid "PartnerId is required" msgstr "PartnerId 为必填项" -#: service/models/partner_identifier.ts:239 +#: service/models/partner_identifier.ts:257 msgctxt "service/models/partner_identifier" msgid "ValidFrom must be less than or equal to ValidTo" msgstr "ValidFrom 必须早于或等于 ValidTo" @@ -106,7 +111,7 @@ msgctxt "web/views/PartnerFormView" msgid "Add Identifier" msgstr "添加标识码" -#: web/views/PartnerFormView.vue:33 +#: web/views/partner_identifier_actions.ts:10 msgctxt "web/views/PartnerFormView" msgid "Copy Identifier" msgstr "复制标识码" @@ -116,7 +121,7 @@ msgctxt "web/views/PartnerFormView" msgid "Country" msgstr "国家" -#: web/views/PartnerFormView.vue:33 +#: web/views/partner_identifier_actions.ts:10 msgctxt "web/views/PartnerFormView" msgid "Create Identifier" msgstr "新建标识码" @@ -126,7 +131,7 @@ msgctxt "web/views/PartnerFormView" msgid "Delete" msgstr "删除" -#: web/views/PartnerFormView.vue:33 +#: web/views/partner_identifier_actions.ts:10 msgctxt "web/views/PartnerFormView" msgid "Delete Identifier" msgstr "删除标识码" @@ -136,12 +141,12 @@ msgctxt "web/views/PartnerFormView" msgid "Edit" msgstr "编辑" -#: web/views/PartnerFormView.vue:33 web/views/PartnerFormView.vue:6 +#: web/views/PartnerFormView.vue:6 web/views/partner_identifier_actions.ts:10 msgctxt "web/views/PartnerFormView" msgid "Edit Identifier" msgstr "编辑标识码" -#: web/views/PartnerFormView.vue:33 +#: web/views/partner_identifier_actions.ts:11 msgctxt "web/views/PartnerFormView" msgid "Identifier" msgstr "标识码" @@ -195,4 +200,3 @@ msgstr "查看标识码" msgctxt "web/views/PartnerIdentifierFormView" msgid "Select Country" msgstr "选择国家" - diff --git a/modules/partner_commercial/service/models/partner_identifier.ts b/modules/partner_commercial/service/models/partner_identifier.ts index 86570b7a1..ab58e075a 100644 --- a/modules/partner_commercial/service/models/partner_identifier.ts +++ b/modules/partner_commercial/service/models/partner_identifier.ts @@ -295,8 +295,11 @@ export default class PartnerIdentifier extends BaseModel { ['Value', '=', value], ], }, - { fields: ['Id', 'PartnerId'], limit: 1 } + { fields: ['Id', 'PartnerId'], limit: 2 } ); + if ((rows?.length || 0) > 1) { + fail(_t('Ambiguous partner identifier match', { scope: 'service/models/partner_identifier' })); + } const row = rows?.[0]; const identifierId = String(row?.Id || '').trim(); if (!identifierId) return { Found: false }; From bea3d28c32fe9b392fab65d63e73acb492bbc44e Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Mon, 21 Sep 2026 17:16:13 +0800 Subject: [PATCH 3/8] fix(service-api): correct W5 typecheck errors in auth and partner Widen setUiKey return type for i18nStore, and cast validation errors with the resolveValidationSummary input type. --- modules/auth/web/stores/auth/language_preference.ts | 2 +- modules/partner/service/models/partner.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/auth/web/stores/auth/language_preference.ts b/modules/auth/web/stores/auth/language_preference.ts index 375cdbe75..23825bc2a 100644 --- a/modules/auth/web/stores/auth/language_preference.ts +++ b/modules/auth/web/stores/auth/language_preference.ts @@ -23,7 +23,7 @@ export async function applyUserLanguagePreference(opts: { languageId: unknown; displayOverrides?: unknown; browseLanguage: (id: string, fields: string[]) => Promise<{ Code?: string } | null | undefined>; - setUiKey: (uiKey: string) => Promise | void; + setUiKey: (uiKey: string) => Promise | unknown; setDisplayOverrides: (overrides: unknown) => void; langToUiKey: (terminologyLang: string) => string; }): Promise { diff --git a/modules/partner/service/models/partner.ts b/modules/partner/service/models/partner.ts index 25ba3965d..afe1024ee 100644 --- a/modules/partner/service/models/partner.ts +++ b/modules/partner/service/models/partner.ts @@ -18,7 +18,7 @@ import type Language from '@/base/service/models/language'; import PartnerContact from './partner_contact'; function isCodeConflict(err: unknown): boolean { - if (resolveValidationSummary(err as { metadata?: Record }).sqlCode === 'sql_unique_violation') { + if (resolveValidationSummary(err as Parameters[0]).sqlCode === 'sql_unique_violation') { return true; } // Application constraint runs before SQL and uses the same uniqueness rule. From dfbd6be9157f4a3463ccb6c1acc4e84500e596ae Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Mon, 21 Sep 2026 17:37:12 +0800 Subject: [PATCH 4/8] fix(service-api): tighten W5 review follow-ups and patch coverage Drop redundant Login language apply (loadUser owns it), make partner code-conflict retries locale-independent, filter inactive Lookup rows, and cover LanguageId i18n apply paths end-to-end in FE unit tests. --- modules/auth/web/pages/Login.vue | 15 +---- modules/auth/web/stores/auth/actions.test.ts | 42 ++++++++++++ modules/auth/web/stores/auth/actions.ts | 12 +++- .../stores/auth/language_preference.test.ts | 39 +++++++++-- .../web/stores/auth/language_preference.ts | 3 +- .../base/service/tests/uom_convert.test.ts | 13 ++++ modules/partner/i18n/partner.pot | 65 ++++++++++--------- modules/partner/i18n/zh_CN.po | 59 +++++++++-------- modules/partner/service/models/partner.ts | 33 +++++----- .../service/models/partner_identifier.ts | 6 +- .../tests/partner_identifier_lookup.test.ts | 19 ++++++ 11 files changed, 208 insertions(+), 98 deletions(-) diff --git a/modules/auth/web/pages/Login.vue b/modules/auth/web/pages/Login.vue index 10f42b891..373f895dd 100644 --- a/modules/auth/web/pages/Login.vue +++ b/modules/auth/web/pages/Login.vue @@ -53,8 +53,6 @@ import { ElForm, ElFormItem, ElInput, ElButton, ElCheckbox, ElAlert, ElCard } fr import { User, Lock } from '@element-plus/icons-vue'; import type { FormRules } from 'element-plus'; import { createTranslate } from '@/web/web/i18n'; -import { useI18nStore, langToUiKey } from '@/web/web/stores/i18nStore'; -import { applyUserLanguagePreference } from '../stores/auth/language_preference'; import { runLoginAuthReady } from './login_auth_ready'; const { _t } = createTranslate('auth', { scope: 'web/pages/Login' }); @@ -131,20 +129,9 @@ async function handleLogin() { await authStore.login(form.username, form.password, '', '', form.rememberMe); - // Apply persisted User.LanguageId when present (terminology lang → UI locale). + // User.LanguageId → UI locale is applied inside loadUser. try { await authStore.loadUser(true); - const i18nStore = useI18nStore(); - const { createStoreByModel } = await import('@/web/web/stores/registry'); - const languageStore = createStoreByModel('base.Language'); - await applyUserLanguagePreference({ - languageId: (authStore.currentUser as any)?.LanguageId, - displayOverrides: (authStore.currentUser as any)?.Preferences?.display ?? null, - browseLanguage: (id, fields) => (languageStore as any).Browse(id, fields), - setUiKey: key => i18nStore.setUiKey(key), - setDisplayOverrides: overrides => i18nStore.setDisplayOverrides(overrides as any), - langToUiKey, - }); } catch { // Preference apply is best-effort; login already succeeded. } diff --git a/modules/auth/web/stores/auth/actions.test.ts b/modules/auth/web/stores/auth/actions.test.ts index 4b03c78e3..eb7f8cfbd 100644 --- a/modules/auth/web/stores/auth/actions.test.ts +++ b/modules/auth/web/stores/auth/actions.test.ts @@ -401,6 +401,10 @@ test('loadUser: applies LanguageId via injected language store', async () => { }); const langBrowse = makeFn(); langBrowse.resolve({ Code: 'zh_CN' }); + const setUiKey = makeFn(); + setUiKey.resolve(undefined); + const setDisplayOverrides = makeFn(); + setDisplayOverrides.returnValue(undefined); const actions = defineAuthActions(mockState as any, mockHelpers as any, { isClient: true, @@ -408,6 +412,13 @@ test('loadUser: applies LanguageId via injected language store', async () => { Browse: langBrowse.fn, Search: async () => [], }), + importI18nStore: async () => ({ + useI18nStore: () => ({ + setUiKey: setUiKey.fn, + setDisplayOverrides: setDisplayOverrides.fn, + }), + langToUiKey: (lang: string) => `ui:${lang}`, + }), }); const ok = await actions.loadUser(true); @@ -421,6 +432,37 @@ test('loadUser: applies LanguageId via injected language store', async () => { 'Preferences', ]); expect((mockState.currentUser.value as any)?.LanguageId).toBe('lang_zh'); + expect(langBrowse.calls.length).toBe(1); + expect(setUiKey.calls[0].args[0]).toBe('ui:zh_CN'); + expect(setDisplayOverrides.calls[0].args[0]).toEqual({ dateFormat: 'YYYY-MM-DD' }); +}); + +test('loadUser: swallows i18n apply failures', async () => { + const mockState = buildMockState(); + const mockHelpers = buildMockHelpers(); + mockState.identity.value = { userId: 'usr_1' } as any; + mockState._recorders.Browse.resolve({ Id: 'usr_1', LanguageId: 'lang_zh' }); + + const actions = defineAuthActions(mockState as any, mockHelpers as any, { + isClient: true, + createLanguageStore: () => ({ + Browse: async () => { + throw new Error('browse down'); + }, + Search: async () => [], + }), + importI18nStore: async () => ({ + useI18nStore: () => ({ + setUiKey: async () => undefined, + setDisplayOverrides: () => undefined, + }), + langToUiKey: (lang: string) => lang, + }), + }); + + const ok = await actions.loadUser(true); + expect(ok).toBe(true); + expect((mockState.currentUser.value as any)?.Id).toBe('usr_1'); }); test('persistLanguagePreference: writes LanguageId and refreshes tokens', async () => { diff --git a/modules/auth/web/stores/auth/actions.ts b/modules/auth/web/stores/auth/actions.ts index 5c56f27ed..21edc4ff8 100644 --- a/modules/auth/web/stores/auth/actions.ts +++ b/modules/auth/web/stores/auth/actions.ts @@ -22,6 +22,14 @@ export type AuthActionDeps = { Browse: (id: string, fields?: string[]) => Promise<{ Code?: string } | null | undefined>; Search: (domain: unknown, opts?: unknown) => Promise>; }; + /** When set, skips dynamic import of `@/web/web/stores/i18nStore`. */ + importI18nStore?: () => Promise<{ + useI18nStore: () => { + setUiKey: (uiKey: string) => Promise | unknown; + setDisplayOverrides: (overrides: unknown) => void; + }; + langToUiKey: (terminologyLang: string) => string; + }>; }; /** @@ -410,7 +418,9 @@ export function defineAuthActions(state: AuthState, helpers: AuthHelpers, deps?: state.currentUser.value = user; // Align FE UI key with the user's LanguageId (covers initAuth refresh paths; Login also applies this). try { - const { useI18nStore, langToUiKey } = await import('@/web/web/stores/i18nStore'); + const { useI18nStore, langToUiKey } = await (deps?.importI18nStore + ? deps.importI18nStore() + : import('@/web/web/stores/i18nStore')); const { applyUserLanguagePreference } = await import('./language_preference'); const i18nStore = useI18nStore(); const languageStore = await getLanguageStore(); diff --git a/modules/auth/web/stores/auth/language_preference.test.ts b/modules/auth/web/stores/auth/language_preference.test.ts index 9ef293e0e..e7b6f2169 100644 --- a/modules/auth/web/stores/auth/language_preference.test.ts +++ b/modules/auth/web/stores/auth/language_preference.test.ts @@ -22,23 +22,48 @@ test('terminologyCodeFromLanguageId: returns trimmed Code from Browse', async () expect(code).toBe('en_US'); }); -test('applyUserLanguagePreference: sets UI key and display overrides', async () => { - const setUiKeyCalls: string[] = []; - let overrides: unknown = 'unset'; +test('applyUserLanguagePreference: sets display overrides before language resolution', async () => { + const order: string[] = []; await applyUserLanguagePreference({ languageId: 'lang_zh', displayOverrides: { dateFormat: 'YYYY-MM-DD' }, - browseLanguage: async () => ({ Code: 'zh_CN' }), + browseLanguage: async () => { + order.push('browse'); + return { Code: 'zh_CN' }; + }, setUiKey: async key => { - setUiKeyCalls.push(key); + order.push(`ui:${key}`); }, setDisplayOverrides: value => { - overrides = value; + order.push(`overrides:${JSON.stringify(value)}`); }, langToUiKey: lang => `ui:${lang}`, }); - expect(setUiKeyCalls).toEqual(['ui:zh_CN']); + expect(order[0]).toBe('overrides:{"dateFormat":"YYYY-MM-DD"}'); + expect(order).toEqual(['overrides:{"dateFormat":"YYYY-MM-DD"}', 'browse', 'ui:ui:zh_CN']); +}); + +test('applyUserLanguagePreference: keeps overrides when setUiKey rejects', async () => { + let overrides: unknown = 'unset'; + let caught: unknown; + try { + await applyUserLanguagePreference({ + languageId: 'lang_zh', + displayOverrides: { dateFormat: 'YYYY-MM-DD' }, + browseLanguage: async () => ({ Code: 'zh_CN' }), + setUiKey: async () => { + throw new Error('setUiKey failed'); + }, + setDisplayOverrides: value => { + overrides = value; + }, + langToUiKey: lang => lang, + }); + } catch (err) { + caught = err; + } expect(overrides).toEqual({ dateFormat: 'YYYY-MM-DD' }); + expect(String((caught as Error)?.message || '')).toContain('setUiKey failed'); }); test('applyUserLanguagePreference: skips setUiKey when LanguageId has no Code', async () => { diff --git a/modules/auth/web/stores/auth/language_preference.ts b/modules/auth/web/stores/auth/language_preference.ts index 23825bc2a..f4486c587 100644 --- a/modules/auth/web/stores/auth/language_preference.ts +++ b/modules/auth/web/stores/auth/language_preference.ts @@ -17,6 +17,7 @@ export async function terminologyCodeFromLanguageId( /** * Apply a User.LanguageId (and optional display overrides) to the FE i18n store. + * Display overrides are applied first so they stick even when language Browse/setUiKey rejects. * Best-effort: callers wrap this when login/auth must continue on i18n failure. */ export async function applyUserLanguagePreference(opts: { @@ -27,9 +28,9 @@ export async function applyUserLanguagePreference(opts: { setDisplayOverrides: (overrides: unknown) => void; langToUiKey: (terminologyLang: string) => string; }): Promise { + opts.setDisplayOverrides(opts.displayOverrides ?? null); const preferredLang = await terminologyCodeFromLanguageId(opts.languageId, opts.browseLanguage); if (preferredLang) { await opts.setUiKey(opts.langToUiKey(preferredLang)); } - opts.setDisplayOverrides(opts.displayOverrides ?? null); } diff --git a/modules/base/service/tests/uom_convert.test.ts b/modules/base/service/tests/uom_convert.test.ts index 21fea81bf..a501f97ac 100644 --- a/modules/base/service/tests/uom_convert.test.ts +++ b/modules/base/service/tests/uom_convert.test.ts @@ -83,3 +83,16 @@ test('base.UoM Convert rejects units from different categories', async () => { expect((error as ChoysumError).domain).toBe('base'); expect((error as ChoysumError).code).toBe('InvalidArgument'); }); + +test('base.UoM Convert rejects a malformed amount', async () => { + const categoryId = await createCategory(); + const gramId = await createUnit(categoryId, '1', true); + let error: unknown; + try { + await UoM.Convert({ Amount: 'not-a-number', FromUoMId: gramId, ToUoMId: gramId }); + } catch (err) { + error = err; + } + expect(error instanceof ChoysumError).toBe(true); + expect((error as ChoysumError).code).toBe('InvalidArgument'); +}); diff --git a/modules/partner/i18n/partner.pot b/modules/partner/i18n/partner.pot index 0a73a5d3f..d9f3a3680 100644 --- a/modules/partner/i18n/partner.pot +++ b/modules/partner/i18n/partner.pot @@ -7,129 +7,129 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: service/models/partner.ts:98 +#: service/models/partner.ts:96 msgctxt "partner.model.Partner.fields" msgid "Active" msgstr "" -#: service/models/partner.ts:72 +#: service/models/partner.ts:70 msgctxt "partner.model.Partner.fields" msgid "Code" msgstr "" -#: service/models/partner.ts:88 +#: service/models/partner.ts:86 msgctxt "partner.model.Partner.fields" msgid "Company" msgstr "" -#: service/models/partner.ts:224 -#: service/models/partner.ts:243 -#: service/models/partner.ts:262 +#: service/models/partner.ts:222 +#: service/models/partner.ts:241 +#: service/models/partner.ts:260 msgctxt "partner.model.Partner.fields" msgid "Computed from contacts; edit contacts to change defaults." msgstr "" -#: service/models/partner.ts:214 +#: service/models/partner.ts:212 msgctxt "partner.model.Partner.fields" msgid "Contacts and Addresses" msgstr "" -#: service/models/partner.ts:170 +#: service/models/partner.ts:168 msgctxt "partner.model.Partner.fields" msgid "Country" msgstr "" -#: service/models/partner.ts:121 +#: service/models/partner.ts:119 msgctxt "partner.model.Partner.fields" msgid "Customer Rank" msgstr "" -#: service/models/partner.ts:242 +#: service/models/partner.ts:240 msgctxt "partner.model.Partner.fields" msgid "Default Billing Address" msgstr "" -#: service/models/partner.ts:223 +#: service/models/partner.ts:221 msgctxt "partner.model.Partner.fields" msgid "Default Contact" msgstr "" -#: service/models/partner.ts:159 +#: service/models/partner.ts:157 msgctxt "partner.model.Partner.fields" msgid "Default Currency" msgstr "" -#: service/models/partner.ts:148 +#: service/models/partner.ts:146 msgctxt "partner.model.Partner.fields" msgid "Default Language" msgstr "" -#: service/models/partner.ts:261 +#: service/models/partner.ts:259 msgctxt "partner.model.Partner.fields" msgid "Default Shipping Address" msgstr "" -#: service/models/partner.ts:188 +#: service/models/partner.ts:186 msgctxt "partner.model.Partner.fields" msgid "Email" msgstr "" -#: service/models/partner.ts:179 +#: service/models/partner.ts:177 msgctxt "partner.model.Partner.fields" msgid "External Reference" msgstr "" -#: service/models/partner.ts:206 +#: service/models/partner.ts:204 msgctxt "partner.model.Partner.fields" msgid "Mobile" msgstr "" -#: service/models/partner.ts:61 +#: service/models/partner.ts:59 msgctxt "partner.model.Partner.fields" msgid "Name" msgstr "" -#: service/models/partner.ts:122 +#: service/models/partner.ts:120 msgctxt "partner.model.Partner.fields" msgid "Non-zero marks the partner as a customer; higher ranks sort first." msgstr "" -#: service/models/partner.ts:135 +#: service/models/partner.ts:133 msgctxt "partner.model.Partner.fields" msgid "Non-zero marks the partner as a supplier; higher ranks sort first." msgstr "" -#: service/models/partner.ts:290 +#: service/models/partner.ts:288 msgctxt "partner.model.Partner.fields" msgid "Notes" msgstr "" -#: service/models/partner.ts:108 +#: service/models/partner.ts:106 msgctxt "partner.model.Partner.fields" msgid "Organization" msgstr "" -#: service/models/partner.ts:197 +#: service/models/partner.ts:195 msgctxt "partner.model.Partner.fields" msgid "Phone" msgstr "" -#: service/models/partner.ts:281 +#: service/models/partner.ts:279 msgctxt "partner.model.Partner.fields" msgid "Sequence" msgstr "" -#: service/models/partner.ts:134 +#: service/models/partner.ts:132 msgctxt "partner.model.Partner.fields" msgid "Supplier Rank" msgstr "" -#: service/models/partner.ts:109 +#: service/models/partner.ts:107 msgctxt "partner.model.Partner.fields" msgid "Uncheck for an individual person rather than an organization." msgstr "" -#: service/models/partner.ts:73 +#: service/models/partner.ts:71 msgctxt "partner.model.Partner.fields" msgid "Unique within the company; stored in uppercase." msgstr "" @@ -224,8 +224,8 @@ msgctxt "partner.model.PartnerContact.fields" msgid "billing/shipping/office/registered/other; drives partner default addresses." msgstr "" -#: service/models/partner.ts:338 -#: service/models/partner.ts:37 +#: service/models/partner.ts:336 +#: service/models/partner.ts:35 msgctxt "service/models/partner" msgid "CompanyId is required" msgstr "" @@ -235,11 +235,16 @@ msgctxt "service/models/partner" msgid "CompanyId must match the active company" msgstr "" -#: service/models/partner.ts:350 +#: service/models/partner.ts:348 msgctxt "service/models/partner" msgid "Partner Code must be unique within the company" msgstr "" +#: service/models/partner.ts:396 +msgctxt "service/models/partner" +msgid "Partner create returned an empty Id" +msgstr "" + #: service/models/partner_contact.ts:191 msgctxt "service/models/partner_contact" msgid "AddressType must be one of billing, shipping, office, registered, other" diff --git a/modules/partner/i18n/zh_CN.po b/modules/partner/i18n/zh_CN.po index 1be3f5fc9..5b223a84b 100644 --- a/modules/partner/i18n/zh_CN.po +++ b/modules/partner/i18n/zh_CN.po @@ -1,127 +1,127 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\nLanguage: zh_CN\n" -#: service/models/partner.ts:98 +#: service/models/partner.ts:96 msgctxt "partner.model.Partner.fields" msgid "Active" msgstr "启用" -#: service/models/partner.ts:72 +#: service/models/partner.ts:70 msgctxt "partner.model.Partner.fields" msgid "Code" msgstr "编码" -#: service/models/partner.ts:88 +#: service/models/partner.ts:86 msgctxt "partner.model.Partner.fields" msgid "Company" msgstr "所属公司" -#: service/models/partner.ts:224 service/models/partner.ts:243 service/models/partner.ts:262 +#: service/models/partner.ts:222 service/models/partner.ts:241 service/models/partner.ts:260 msgctxt "partner.model.Partner.fields" msgid "Computed from contacts; edit contacts to change defaults." msgstr "由联系人计算得出;请通过编辑联系人更改默认值。" -#: service/models/partner.ts:214 +#: service/models/partner.ts:212 msgctxt "partner.model.Partner.fields" msgid "Contacts and Addresses" msgstr "联系人与地址" -#: service/models/partner.ts:170 +#: service/models/partner.ts:168 msgctxt "partner.model.Partner.fields" msgid "Country" msgstr "国家" -#: service/models/partner.ts:121 +#: service/models/partner.ts:119 msgctxt "partner.model.Partner.fields" msgid "Customer Rank" msgstr "客户等级" -#: service/models/partner.ts:242 +#: service/models/partner.ts:240 msgctxt "partner.model.Partner.fields" msgid "Default Billing Address" msgstr "默认开票地址" -#: service/models/partner.ts:223 +#: service/models/partner.ts:221 msgctxt "partner.model.Partner.fields" msgid "Default Contact" msgstr "默认联系人" -#: service/models/partner.ts:159 +#: service/models/partner.ts:157 msgctxt "partner.model.Partner.fields" msgid "Default Currency" msgstr "默认币种" -#: service/models/partner.ts:148 +#: service/models/partner.ts:146 msgctxt "partner.model.Partner.fields" msgid "Default Language" msgstr "默认语言" -#: service/models/partner.ts:261 +#: service/models/partner.ts:259 msgctxt "partner.model.Partner.fields" msgid "Default Shipping Address" msgstr "默认收货地址" -#: service/models/partner.ts:188 +#: service/models/partner.ts:186 msgctxt "partner.model.Partner.fields" msgid "Email" msgstr "邮箱" -#: service/models/partner.ts:179 +#: service/models/partner.ts:177 msgctxt "partner.model.Partner.fields" msgid "External Reference" msgstr "外部参考" -#: service/models/partner.ts:206 +#: service/models/partner.ts:204 msgctxt "partner.model.Partner.fields" msgid "Mobile" msgstr "手机" -#: service/models/partner.ts:61 +#: service/models/partner.ts:59 msgctxt "partner.model.Partner.fields" msgid "Name" msgstr "名称" -#: service/models/partner.ts:122 +#: service/models/partner.ts:120 msgctxt "partner.model.Partner.fields" msgid "Non-zero marks the partner as a customer; higher ranks sort first." msgstr "非零表示客户;数值越大排序越靠前。" -#: service/models/partner.ts:135 +#: service/models/partner.ts:133 msgctxt "partner.model.Partner.fields" msgid "Non-zero marks the partner as a supplier; higher ranks sort first." msgstr "非零表示供应商;数值越大排序越靠前。" -#: service/models/partner.ts:290 +#: service/models/partner.ts:288 msgctxt "partner.model.Partner.fields" msgid "Notes" msgstr "备注" -#: service/models/partner.ts:108 +#: service/models/partner.ts:106 msgctxt "partner.model.Partner.fields" msgid "Organization" msgstr "组织主体" -#: service/models/partner.ts:197 +#: service/models/partner.ts:195 msgctxt "partner.model.Partner.fields" msgid "Phone" msgstr "电话" -#: service/models/partner.ts:281 +#: service/models/partner.ts:279 msgctxt "partner.model.Partner.fields" msgid "Sequence" msgstr "排序" -#: service/models/partner.ts:134 +#: service/models/partner.ts:132 msgctxt "partner.model.Partner.fields" msgid "Supplier Rank" msgstr "供应商等级" -#: service/models/partner.ts:109 +#: service/models/partner.ts:107 msgctxt "partner.model.Partner.fields" msgid "Uncheck for an individual person rather than an organization." msgstr "取消勾选表示个人,而非组织。" -#: service/models/partner.ts:73 +#: service/models/partner.ts:71 msgctxt "partner.model.Partner.fields" msgid "Unique within the company; stored in uppercase." msgstr "在公司内唯一;存储为大写。" @@ -216,7 +216,7 @@ msgctxt "partner.model.PartnerContact.fields" msgid "billing/shipping/office/registered/other; drives partner default addresses." msgstr "billing/shipping/office/registered/other;驱动业务伙伴默认地址。" -#: service/models/partner.ts:338 service/models/partner.ts:37 +#: service/models/partner.ts:336 service/models/partner.ts:35 msgctxt "service/models/partner" msgid "CompanyId is required" msgstr "CompanyId 为必填项" @@ -226,11 +226,16 @@ msgctxt "service/models/partner" msgid "CompanyId must match the active company" msgstr "CompanyId 必须与当前活动公司一致" -#: service/models/partner.ts:350 +#: service/models/partner.ts:348 msgctxt "service/models/partner" msgid "Partner Code must be unique within the company" msgstr "伙伴编码在同一公司内必须唯一" +#: service/models/partner.ts:396 +msgctxt "service/models/partner" +msgid "Partner create returned an empty Id" +msgstr "合作伙伴创建返回了空的 Id" + #: service/models/partner_contact.ts:191 msgctxt "service/models/partner_contact" msgid "AddressType must be one of billing, shipping, office, registered, other" diff --git a/modules/partner/service/models/partner.ts b/modules/partner/service/models/partner.ts index afe1024ee..28477733e 100644 --- a/modules/partner/service/models/partner.ts +++ b/modules/partner/service/models/partner.ts @@ -6,7 +6,6 @@ import type { ModelCtor, RowOf } from '@/core/service'; import { Constraint } from '@/core/service/api/constraint'; import { getActiveCompanyId } from '@/core/service/api/context'; import type { FieldSelection, Insertable } from '@/core/service/api'; -import { resolveValidationSummary } from '@/core/service/api/validation'; import { normalizeRefId } from '@/core/service/utils/normalization'; import PartnerCollaborationModel from '../mixins/partner_collaboration_model'; import { _t, _lt } from '../i18n'; @@ -17,14 +16,8 @@ import type Currency from '@/base/service/models/currency'; import type Language from '@/base/service/models/language'; import PartnerContact from './partner_contact'; -function isCodeConflict(err: unknown): boolean { - if (resolveValidationSummary(err as Parameters[0]).sqlCode === 'sql_unique_violation') { - return true; - } - // Application constraint runs before SQL and uses the same uniqueness rule. - const message = String((err as { message?: unknown })?.message || ''); - return message.includes('Partner Code must be unique'); -} +/** Matches Partner.Code varchar size. */ +const CODE_MAX_LENGTH = 40; export type PartnerFindOrCreateReq = { Code: string; @@ -44,7 +37,7 @@ function requireSessionCompanyId(): string { } function codeSeedFromName(name: string): string { - const seed = name.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 32); + const seed = name.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, CODE_MAX_LENGTH); return seed || 'P'; } @@ -399,9 +392,11 @@ export default class Partner extends PartnerCollaborationModel { const name = req?.Name != null && String(req.Name).trim() ? assertRequiredText(req.Name, 'Name') : code; try { const created = await this.Create({ Name: name, Code: code, CompanyId: companyId } as Partial, ['Id'] as any); - return { PartnerId: String((created as { Id?: unknown }).Id || ''), Created: true }; + const partnerId = String((created as { Id?: unknown }).Id || '').trim(); + if (!partnerId) fail(_t('Partner create returned an empty Id', { scope: 'service/models/partner' })); + return { PartnerId: partnerId, Created: true }; } catch (err) { - if (!isCodeConflict(err)) throw err; + // Uniqueness errors are localized; re-query decides whether another writer won. const again = await this.Search( { And: [['CompanyId', '=', companyId], ['Code', '=', code]] }, { fields: ['Id'], limit: 1 } @@ -415,7 +410,7 @@ export default class Partner extends PartnerCollaborationModel { /** * Create a partner from a display name (relation typeahead). * Code is derived from the name and disambiguated within the session company. - * Allocation retries on unique Code conflict rather than a pre-check Search loop. + * Allocation retries when the candidate code is already taken (locale-independent). */ static async NameCreate> | undefined = undefined>( this: C, @@ -432,13 +427,13 @@ export default class Partner extends PartnerCollaborationModel { const display = assertRequiredText(name, 'Name'); const seed = codeSeedFromName(display); const explicitCode = String(partnerValues.Code || '').trim().toUpperCase(); - let code = explicitCode || seed.slice(0, 40); + let code = explicitCode || seed.slice(0, CODE_MAX_LENGTH); const maxAttempts = explicitCode ? 1 : 32; let lastErr: unknown; for (let attempt = 0; attempt < maxAttempts; attempt++) { if (!explicitCode && attempt > 0) { const suffix = String(attempt); - code = `${seed.slice(0, Math.max(1, 40 - suffix.length))}${suffix}`; + code = `${seed.slice(0, Math.max(1, CODE_MAX_LENGTH - suffix.length))}${suffix}`; } try { return await (this as unknown as typeof Partner).Create( @@ -447,7 +442,13 @@ export default class Partner extends PartnerCollaborationModel { ); } catch (err) { lastErr = err; - if (explicitCode || !isCodeConflict(err)) throw err; + if (explicitCode) throw err; + // Locale-independent: retry only when this code is already taken. + const taken = await (this as unknown as typeof Partner).Search( + { And: [['CompanyId', '=', companyId], ['Code', '=', code]] }, + { fields: ['Id'], limit: 1 } + ); + if (!String(taken?.[0]?.Id || '').trim()) throw err; } } throw lastErr; diff --git a/modules/partner_commercial/service/models/partner_identifier.ts b/modules/partner_commercial/service/models/partner_identifier.ts index ab58e075a..9a651f805 100644 --- a/modules/partner_commercial/service/models/partner_identifier.ts +++ b/modules/partner_commercial/service/models/partner_identifier.ts @@ -279,7 +279,7 @@ export default class PartnerIdentifier extends BaseModel { } /** - * Find a partner by commercial identifier within the session company. + * Find a partner by an active commercial identifier within the session company. * Type is matched lowercase and value uppercase, matching stored normalization. */ static async Lookup(req: PartnerIdentifierLookupReq): Promise { @@ -293,6 +293,7 @@ export default class PartnerIdentifier extends BaseModel { ['CompanyId', '=', companyId], ['IdentifierType', '=', identifierType], ['Value', '=', value], + ['IsActive', '=', true], ], }, { fields: ['Id', 'PartnerId'], limit: 2 } @@ -304,10 +305,11 @@ export default class PartnerIdentifier extends BaseModel { const identifierId = String(row?.Id || '').trim(); if (!identifierId) return { Found: false }; const partnerId = normalizeRefId(row?.PartnerId) || ''; + if (!partnerId) return { Found: false }; return { Found: true, PartnerIdentifierId: identifierId, - PartnerId: partnerId || undefined, + PartnerId: partnerId, }; } } diff --git a/modules/partner_commercial/service/tests/partner_identifier_lookup.test.ts b/modules/partner_commercial/service/tests/partner_identifier_lookup.test.ts index 45bc8bea5..70e918ec9 100644 --- a/modules/partner_commercial/service/tests/partner_identifier_lookup.test.ts +++ b/modules/partner_commercial/service/tests/partner_identifier_lookup.test.ts @@ -64,3 +64,22 @@ test('partner.PartnerIdentifier Lookup matches normalized type and value', async const missing = await withCompany(companyId, () => PartnerIdentifier.Lookup({ IdentifierType: 'vat', Value: 'nope' })); expect(missing.Found).toBe(false); }); + +test('partner.PartnerIdentifier Lookup ignores inactive identifiers', async () => { + const companyId = await ensureCompanyId(); + const found = await withCompany(companyId, async () => { + const partner = await Partner.NameCreate(uid('Inactive'), undefined, { returnFields: ['Id'] }); + await PartnerIdentifier.Create( + { + PartnerId: String((partner as any).Id), + CompanyId: companyId, + IdentifierType: 'vat', + Value: 'inactive-1', + IsActive: false, + } as any, + ['Id'] as any + ); + return PartnerIdentifier.Lookup({ IdentifierType: 'vat', Value: 'inactive-1' }); + }); + expect(found.Found).toBe(false); +}); From 73b83eb72b18d9f87c9d909e50999e63ce4d6cef Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Mon, 21 Sep 2026 17:47:25 +0800 Subject: [PATCH 5/8] fix(service-api): harden W5 edge paths and cover default i18n imports Guard UoM factor parsing, backfill warnings, preference lookup failures, and FindOrCreate recovery Search; cover default Language/i18n dynamic imports so actions.ts patch coverage reaches 100%. --- .../auth/service/hook/post_upgrade.test.ts | 42 +++++++++++++++++++ modules/auth/service/hook/post_upgrade.ts | 3 +- .../preferences/preferences_language.ts | 7 ++-- modules/auth/web/stores/auth/actions.test.ts | 36 ++++++++++++++++ modules/auth/web/stores/auth/actions.ts | 18 +++++--- modules/base/service/models/_uom_convert.ts | 19 ++++++--- .../base/service/tests/uom_convert.test.ts | 34 +++++++++++++++ modules/partner/service/models/partner.ts | 31 +++++++++----- 8 files changed, 164 insertions(+), 26 deletions(-) diff --git a/modules/auth/service/hook/post_upgrade.test.ts b/modules/auth/service/hook/post_upgrade.test.ts index cacf4a6c4..6c668f7d2 100644 --- a/modules/auth/service/hook/post_upgrade.test.ts +++ b/modules/auth/service/hook/post_upgrade.test.ts @@ -26,6 +26,28 @@ test('AuthUserLanguageHooks.backfillUserLanguageId: sqlite SQL filters active tr expect(calls[0].sql).toContain('trim(auth_user.language)'); }); +test('AuthUserLanguageHooks.backfillUserLanguageId: mysql SQL filters active trimmed codes', async () => { + const calls: Array<{ sql: string; params: string }> = []; + const prev = (globalThis as any).$choysum; + (globalThis as any).$choysum = { + ...(prev || {}), + db: { + dialectName: 'mysql', + execute: async (sql: string, params: string) => { + calls.push({ sql, params }); + }, + }, + }; + try { + await AuthUserLanguageHooks.backfillUserLanguageId(); + } finally { + (globalThis as any).$choysum = prev; + } + expect(calls.length).toBe(1); + expect(calls[0].sql).toContain('is_active = true'); + expect(calls[0].sql).toContain('trim(auth_user.language)'); +}); + test('AuthUserLanguageHooks.backfillUserLanguageId: postgres SQL filters active trimmed codes', async () => { const calls: Array<{ sql: string; params: string }> = []; const prev = (globalThis as any).$choysum; @@ -75,3 +97,23 @@ test('AuthUserLanguageHooks.backfillUserLanguageId: no-ops without db', async () (globalThis as any).$choysum = prev; } }); + +test('AuthUserLanguageHooks.backfillUserLanguageId: unknown dialect executes no SQL', async () => { + const calls: Array<{ sql: string; params: string }> = []; + const prev = (globalThis as any).$choysum; + (globalThis as any).$choysum = { + ...(prev || {}), + db: { + dialectName: 'oracle', + execute: async (sql: string, params: string) => { + calls.push({ sql, params }); + }, + }, + }; + try { + await AuthUserLanguageHooks.backfillUserLanguageId(); + } finally { + (globalThis as any).$choysum = prev; + } + expect(calls.length).toBe(0); +}); diff --git a/modules/auth/service/hook/post_upgrade.ts b/modules/auth/service/hook/post_upgrade.ts index e69a97f1c..bb45b5c20 100644 --- a/modules/auth/service/hook/post_upgrade.ts +++ b/modules/auth/service/hook/post_upgrade.ts @@ -37,8 +37,9 @@ export class AuthUserLanguageHooks { if (!sql) return; try { await db.execute(sql, '[]'); - } catch { + } catch (err) { // Old language column may already be absent; users can set LanguageId again. + console.warn('[auth] User.LanguageId backfill failed', err); } } } diff --git a/modules/auth/web/components/preferences/preferences_language.ts b/modules/auth/web/components/preferences/preferences_language.ts index de90279eb..52229c442 100644 --- a/modules/auth/web/components/preferences/preferences_language.ts +++ b/modules/auth/web/components/preferences/preferences_language.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: Apache-2.0 +import { terminologyCodeFromLanguageId } from '@/auth/web/stores/auth/language_preference'; + /** * Resolve a language Code from User.LanguageId for the preferences dialog. * Browse failures yield '' so the dialog can fall back to session terminology. @@ -9,11 +11,8 @@ export async function resolveLanguageCodeFromId( languageId: unknown, browseLanguage: (id: string, fields: string[]) => Promise<{ Code?: string } | null | undefined> ): Promise { - const id = String(languageId || '').trim(); - if (!id) return ''; try { - const row = await browseLanguage(id, ['Code']); - return String(row?.Code || '').trim(); + return await terminologyCodeFromLanguageId(languageId, browseLanguage); } catch { return ''; } diff --git a/modules/auth/web/stores/auth/actions.test.ts b/modules/auth/web/stores/auth/actions.test.ts index eb7f8cfbd..aeefd1df1 100644 --- a/modules/auth/web/stores/auth/actions.test.ts +++ b/modules/auth/web/stores/auth/actions.test.ts @@ -465,6 +465,24 @@ test('loadUser: swallows i18n apply failures', async () => { expect((mockState.currentUser.value as any)?.Id).toBe('usr_1'); }); +test('loadUser: exercises default i18nStore and Language registry imports', async () => { + const mockState = buildMockState(); + const mockHelpers = buildMockHelpers(); + mockState.identity.value = { userId: 'usr_1' } as any; + mockState._recorders.Browse.resolve({ + Id: 'usr_1', + Username: 'ada', + LanguageId: 'lang_zh', + Preferences: { display: null }, + }); + + // No createLanguageStore / importI18nStore — hit the production dynamic-import branches. + const actions = defineAuthActions(mockState as any, mockHelpers as any, { isClient: true }); + const ok = await actions.loadUser(true); + expect(ok).toBe(true); + expect((mockState.currentUser.value as any)?.Id).toBe('usr_1'); +}); + test('persistLanguagePreference: writes LanguageId and refreshes tokens', async () => { const mockState = buildMockState(); const mockHelpers = buildMockHelpers(); @@ -522,3 +540,21 @@ test('persistLanguagePreference: no-ops when anonymous or language missing', asy await actions2.persistLanguagePreference('en_US'); expect(mockState._recorders.UpdateById.calls.length).toBe(0); }); + +test('persistLanguagePreference: no-ops when language lookup throws', async () => { + const mockState = buildMockState(); + const mockHelpers = buildMockHelpers(); + mockState.isAuthenticated.value = true; + mockState.currentUser.value = { Id: 'usr_1' } as any; + const actions = defineAuthActions(mockState as any, mockHelpers as any, { + isClient: true, + createLanguageStore: () => ({ + Browse: async () => null, + Search: async () => { + throw new Error('registry down'); + }, + }), + }); + await actions.persistLanguagePreference('en_US'); + expect(mockState._recorders.UpdateById.calls.length).toBe(0); +}); diff --git a/modules/auth/web/stores/auth/actions.ts b/modules/auth/web/stores/auth/actions.ts index 21edc4ff8..09a246d1a 100644 --- a/modules/auth/web/stores/auth/actions.ts +++ b/modules/auth/web/stores/auth/actions.ts @@ -540,12 +540,18 @@ export function defineAuthActions(state: AuthState, helpers: AuthHelpers, deps?: if (!userId) { return; } - const languageStore = await getLanguageStore(); - const rows = (await languageStore.Search( - { And: [['Code', '=', terminologyLang], ['IsActive', '=', true]] } as any, - { fields: ['Id'], limit: 1 } as any - )) as Array<{ Id?: string }>; - const languageId = String(rows?.[0]?.Id || '').trim(); + let languageId = ''; + try { + const languageStore = await getLanguageStore(); + const rows = (await languageStore.Search( + { And: [['Code', '=', terminologyLang], ['IsActive', '=', true]] } as any, + { fields: ['Id'], limit: 1 } as any + )) as Array<{ Id?: string }>; + languageId = String(rows?.[0]?.Id || '').trim(); + } catch { + // Best-effort preference write; FE locale can still live in i18nStore localStorage. + return; + } if (!languageId) return; await state.userStore.UpdateById(userId, { LanguageId: languageId } as any, ['Id', 'LanguageId'] as any); if (state.currentUser.value) { diff --git a/modules/base/service/models/_uom_convert.ts b/modules/base/service/models/_uom_convert.ts index 6f76d55ac..7a50be626 100644 --- a/modules/base/service/models/_uom_convert.ts +++ b/modules/base/service/models/_uom_convert.ts @@ -65,11 +65,20 @@ export async function convertUoM(UoMModel: UoMOps, params: UoMConvertParams | un invalid(_t('UoMs must belong to the same category', { scope: 'service/models/_uom_convert' })); } - const fromFactor = from.Factor instanceof Decimal ? from.Factor : new Decimal(String(from.Factor)); - const toFactor = to.Factor instanceof Decimal ? to.Factor : new Decimal(String(to.Factor)); - if (!fromFactor.isFinite() || fromFactor.lte(0) || !toFactor.isFinite() || toFactor.lte(0)) { - invalid(_t('UoM factor must be greater than 0', { scope: 'service/models/_uom_convert' })); - } + const parseFactor = (value: unknown): Decimal => { + try { + const factor = value instanceof Decimal ? value : new Decimal(String(value)); + if (!factor.isFinite() || factor.lte(0)) { + invalid(_t('UoM factor must be greater than 0', { scope: 'service/models/_uom_convert' })); + } + return factor; + } catch (err) { + if (err instanceof ChoysumError) throw err; + invalid(_t('UoM factor must be greater than 0', { scope: 'service/models/_uom_convert' })); + } + }; + const fromFactor = parseFactor(from.Factor); + const toFactor = parseFactor(to.Factor); const converted = amount.mul(fromFactor).div(toFactor); return { Amount: roundToUoM(converted, to.Rounding) }; diff --git a/modules/base/service/tests/uom_convert.test.ts b/modules/base/service/tests/uom_convert.test.ts index a501f97ac..d4f87c849 100644 --- a/modules/base/service/tests/uom_convert.test.ts +++ b/modules/base/service/tests/uom_convert.test.ts @@ -96,3 +96,37 @@ test('base.UoM Convert rejects a malformed amount', async () => { expect(error instanceof ChoysumError).toBe(true); expect((error as ChoysumError).code).toBe('InvalidArgument'); }); + +test('base.UoM Convert rejects a non-decimal Factor as InvalidArgument', async () => { + const categoryId = await createCategory(); + const gramId = await createUnit(categoryId, '1', true); + const bad = await UoM.Create( + { + Name: uid('BadFactor'), + CategoryId: categoryId, + IsReference: false, + Factor: '1', + IsActive: true, + } as any, + ['Id'] as any + ); + const badId = String((bad as any).Id); + // Bypass model validation by converting with a stub Browse that returns garbage Factor. + const { convertUoM } = await import('@/base/service/models/_uom_convert'); + let error: unknown; + try { + await convertUoM( + { + Browse: async (id: string) => { + if (id === gramId) return { Id: gramId, CategoryId: categoryId, Factor: '1', Rounding: null } as any; + return { Id: badId, CategoryId: categoryId, Factor: 'not-a-factor', Rounding: null } as any; + }, + }, + { Amount: '1', FromUoMId: gramId, ToUoMId: badId } + ); + } catch (err) { + error = err; + } + expect(error instanceof ChoysumError).toBe(true); + expect((error as ChoysumError).code).toBe('InvalidArgument'); +}); diff --git a/modules/partner/service/models/partner.ts b/modules/partner/service/models/partner.ts index 28477733e..57f400803 100644 --- a/modules/partner/service/models/partner.ts +++ b/modules/partner/service/models/partner.ts @@ -397,11 +397,16 @@ export default class Partner extends PartnerCollaborationModel { return { PartnerId: partnerId, Created: true }; } catch (err) { // Uniqueness errors are localized; re-query decides whether another writer won. - const again = await this.Search( - { And: [['CompanyId', '=', companyId], ['Code', '=', code]] }, - { fields: ['Id'], limit: 1 } - ); - const racedId = String(again?.[0]?.Id || '').trim(); + let racedId = ''; + try { + const again = await this.Search( + { And: [['CompanyId', '=', companyId], ['Code', '=', code]] }, + { fields: ['Id'], limit: 1 } + ); + racedId = String(again?.[0]?.Id || '').trim(); + } catch { + throw err; + } if (racedId) return { PartnerId: racedId, Created: false }; throw err; } @@ -444,11 +449,17 @@ export default class Partner extends PartnerCollaborationModel { lastErr = err; if (explicitCode) throw err; // Locale-independent: retry only when this code is already taken. - const taken = await (this as unknown as typeof Partner).Search( - { And: [['CompanyId', '=', companyId], ['Code', '=', code]] }, - { fields: ['Id'], limit: 1 } - ); - if (!String(taken?.[0]?.Id || '').trim()) throw err; + let takenId = ''; + try { + const taken = await (this as unknown as typeof Partner).Search( + { And: [['CompanyId', '=', companyId], ['Code', '=', code]] }, + { fields: ['Id'], limit: 1 } + ); + takenId = String(taken?.[0]?.Id || '').trim(); + } catch { + throw err; + } + if (!takenId) throw err; } } throw lastErr; From fee05dccf43fcce28382dbc9fccfc925bca761bb Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Mon, 21 Sep 2026 18:36:12 +0800 Subject: [PATCH 6/8] fix(service-api): ignore bad UoM rounding and normalize dialect names Non-numeric rounding no longer throws a raw decimal error, and LanguageId backfill matches dialect names after trim and lowercase. --- .../auth/service/hook/post_upgrade.test.ts | 21 +++++++++++++++++++ modules/auth/service/hook/post_upgrade.ts | 2 +- modules/base/service/models/_uom_convert.ts | 7 ++++++- .../base/service/tests/uom_convert.test.ts | 14 +++++++++++++ .../service/models/partner_identifier.ts | 1 + 5 files changed, 43 insertions(+), 2 deletions(-) diff --git a/modules/auth/service/hook/post_upgrade.test.ts b/modules/auth/service/hook/post_upgrade.test.ts index 6c668f7d2..453d31650 100644 --- a/modules/auth/service/hook/post_upgrade.test.ts +++ b/modules/auth/service/hook/post_upgrade.test.ts @@ -48,6 +48,27 @@ test('AuthUserLanguageHooks.backfillUserLanguageId: mysql SQL filters active tri expect(calls[0].sql).toContain('trim(auth_user.language)'); }); +test('AuthUserLanguageHooks.backfillUserLanguageId: dialect name is trimmed and lowercased', async () => { + const calls: Array<{ sql: string; params: string }> = []; + const prev = (globalThis as any).$choysum; + (globalThis as any).$choysum = { + ...(prev || {}), + db: { + dialectName: ' SQLite ', + execute: async (sql: string, params: string) => { + calls.push({ sql, params }); + }, + }, + }; + try { + await AuthUserLanguageHooks.backfillUserLanguageId(); + } finally { + (globalThis as any).$choysum = prev; + } + expect(calls.length).toBe(1); + expect(calls[0].sql).toContain('is_active = 1'); +}); + test('AuthUserLanguageHooks.backfillUserLanguageId: postgres SQL filters active trimmed codes', async () => { const calls: Array<{ sql: string; params: string }> = []; const prev = (globalThis as any).$choysum; diff --git a/modules/auth/service/hook/post_upgrade.ts b/modules/auth/service/hook/post_upgrade.ts index bb45b5c20..cf9498005 100644 --- a/modules/auth/service/hook/post_upgrade.ts +++ b/modules/auth/service/hook/post_upgrade.ts @@ -12,7 +12,7 @@ export class AuthUserLanguageHooks { static async backfillUserLanguageId(): Promise { const db = (globalThis as { $choysum?: { db?: { dialectName?: string; execute?: (sql: string, params: string) => Promise } } }).$choysum?.db; if (!db?.execute || !db.dialectName) return; - const dialect = String(db.dialectName); + const dialect = String(db.dialectName).trim().toLowerCase(); let sql = ''; if (dialect === 'sqlite') { sql = `UPDATE auth_user diff --git a/modules/base/service/models/_uom_convert.ts b/modules/base/service/models/_uom_convert.ts index 7a50be626..6d7512308 100644 --- a/modules/base/service/models/_uom_convert.ts +++ b/modules/base/service/models/_uom_convert.ts @@ -32,7 +32,12 @@ function invalid(message: string): never { */ function roundToUoM(amount: Decimal, rounding: unknown): Decimal { if (rounding == null || rounding === '') return amount; - const step = rounding instanceof Decimal ? rounding : new Decimal(String(rounding)); + let step: Decimal; + try { + step = rounding instanceof Decimal ? rounding : new Decimal(String(rounding)); + } catch { + return amount; + } if (!step.isFinite() || step.lte(0)) return amount; return amount.div(step).toDecimalPlaces(0, Decimal.ROUND_HALF_UP).mul(step); } diff --git a/modules/base/service/tests/uom_convert.test.ts b/modules/base/service/tests/uom_convert.test.ts index d4f87c849..a84fd4c24 100644 --- a/modules/base/service/tests/uom_convert.test.ts +++ b/modules/base/service/tests/uom_convert.test.ts @@ -130,3 +130,17 @@ test('base.UoM Convert rejects a non-decimal Factor as InvalidArgument', async ( expect(error instanceof ChoysumError).toBe(true); expect((error as ChoysumError).code).toBe('InvalidArgument'); }); + +test('base.UoM Convert ignores a non-numeric rounding step', async () => { + const { convertUoM } = await import('@/base/service/models/_uom_convert'); + const result = await convertUoM( + { + Browse: async (id: string) => { + if (id === 'from') return { Id: 'from', CategoryId: 'cat', Factor: '1', Rounding: null } as any; + return { Id: 'to', CategoryId: 'cat', Factor: '1', Rounding: 'nope' } as any; + }, + }, + { Amount: '1.5', FromUoMId: 'from', ToUoMId: 'to' } + ); + expect(result.Amount.eq(new Decimal('1.5'))).toBe(true); +}); diff --git a/modules/partner_commercial/service/models/partner_identifier.ts b/modules/partner_commercial/service/models/partner_identifier.ts index 9a651f805..1078630bf 100644 --- a/modules/partner_commercial/service/models/partner_identifier.ts +++ b/modules/partner_commercial/service/models/partner_identifier.ts @@ -281,6 +281,7 @@ export default class PartnerIdentifier extends BaseModel { /** * Find a partner by an active commercial identifier within the session company. * Type is matched lowercase and value uppercase, matching stored normalization. + * ValidFrom/ValidTo are not applied; callers that need a validity window must filter themselves. */ static async Lookup(req: PartnerIdentifierLookupReq): Promise { const companyId = String(getActiveCompanyId() || '').trim(); From 7d9c193cebca428b020c205fc13042beaadc6a6b Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Mon, 21 Sep 2026 19:57:07 +0800 Subject: [PATCH 7/8] fix(auth): resolve LanguageId refs and drop duplicate login load extractUserMetadata and the FE language helper accept a ManyToOne Id object, and Login no longer calls loadUser again after login(). --- modules/auth/service/models/user/user.ts | 2 +- .../preferences/OPreferencesDialog.vue | 3 +-- modules/auth/web/pages/Login.vue | 7 ------- .../stores/auth/language_preference.test.ts | 18 ++++++++++++++++++ .../web/stores/auth/language_preference.ts | 11 ++++++++++- 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/modules/auth/service/models/user/user.ts b/modules/auth/service/models/user/user.ts index 8a88ece7f..50e358763 100644 --- a/modules/auth/service/models/user/user.ts +++ b/modules/auth/service/models/user/user.ts @@ -538,7 +538,7 @@ export default class User extends AttachmentOwnerMixin { } let language: string | undefined; - const languageId = String(user?.LanguageId || '').trim(); + const languageId = normalizeRefId(user?.LanguageId) || ''; if (languageId) { try { const row = await Language.Browse(languageId, ['Code', 'IsActive']); diff --git a/modules/auth/web/components/preferences/OPreferencesDialog.vue b/modules/auth/web/components/preferences/OPreferencesDialog.vue index aaacb28b0..500a31041 100644 --- a/modules/auth/web/components/preferences/OPreferencesDialog.vue +++ b/modules/auth/web/components/preferences/OPreferencesDialog.vue @@ -124,8 +124,7 @@ async function loadTimezoneOptions() { } async function syncLanguageFromUser() { - const languageId = String(currentUser.value?.LanguageId || '').trim(); - const code = await resolveLanguageCodeFromId(languageId, (id, fields) => + const code = await resolveLanguageCodeFromId(currentUser.value?.LanguageId, (id, fields) => (languageStore as any).Browse(id, fields) ); savedLanguageCode.value = code; diff --git a/modules/auth/web/pages/Login.vue b/modules/auth/web/pages/Login.vue index 373f895dd..f41b7dcd5 100644 --- a/modules/auth/web/pages/Login.vue +++ b/modules/auth/web/pages/Login.vue @@ -129,13 +129,6 @@ async function handleLogin() { await authStore.login(form.username, form.password, '', '', form.rememberMe); - // User.LanguageId → UI locale is applied inside loadUser. - try { - await authStore.loadUser(true); - } catch { - // Preference apply is best-effort; login already succeeded. - } - handleRedirect(); } catch (err) { if (err instanceof ChoysumError) { diff --git a/modules/auth/web/stores/auth/language_preference.test.ts b/modules/auth/web/stores/auth/language_preference.test.ts index e7b6f2169..ab5e15d3e 100644 --- a/modules/auth/web/stores/auth/language_preference.test.ts +++ b/modules/auth/web/stores/auth/language_preference.test.ts @@ -22,6 +22,24 @@ test('terminologyCodeFromLanguageId: returns trimmed Code from Browse', async () expect(code).toBe('en_US'); }); +test('terminologyCodeFromLanguageId: unwraps a ManyToOne Id reference', async () => { + const code = await terminologyCodeFromLanguageId({ Id: ' lang_2 ' }, async id => { + expect(id).toBe('lang_2'); + return { Code: 'zh_CN' }; + }); + expect(code).toBe('zh_CN'); +}); + +test('terminologyCodeFromLanguageId: empty object skips Browse', async () => { + let browsed = false; + const code = await terminologyCodeFromLanguageId({}, async () => { + browsed = true; + return { Code: 'zh_CN' }; + }); + expect(code).toBe(''); + expect(browsed).toBe(false); +}); + test('applyUserLanguagePreference: sets display overrides before language resolution', async () => { const order: string[] = []; await applyUserLanguagePreference({ diff --git a/modules/auth/web/stores/auth/language_preference.ts b/modules/auth/web/stores/auth/language_preference.ts index f4486c587..3fad079c3 100644 --- a/modules/auth/web/stores/auth/language_preference.ts +++ b/modules/auth/web/stores/auth/language_preference.ts @@ -3,18 +3,27 @@ /** * Resolve a terminology POSIX code from a base.Language id via Browse. + * Accepts a raw id or a ManyToOne `{ Id }` reference. * Returns '' when the id is empty or Browse yields no Code. */ export async function terminologyCodeFromLanguageId( languageId: unknown, browseLanguage: (id: string, fields: string[]) => Promise<{ Code?: string } | null | undefined> ): Promise { - const id = String(languageId || '').trim(); + const id = languageRefId(languageId); if (!id) return ''; const row = await browseLanguage(id, ['Code']); return String(row?.Code || '').trim(); } +function languageRefId(value: unknown): string { + if (value == null || Array.isArray(value)) return ''; + const raw = + typeof value === 'object' && value !== null && 'Id' in value ? (value as { Id?: unknown }).Id : value; + const id = String(raw ?? '').trim(); + return id && id !== '[object Object]' ? id : ''; +} + /** * Apply a User.LanguageId (and optional display overrides) to the FE i18n store. * Display overrides are applied first so they stick even when language Browse/setUiKey rejects. From 7b34dbc0a945b043728a29bb1c6a6cbd321cb781 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Mon, 21 Sep 2026 20:09:41 +0800 Subject: [PATCH 8/8] fix(service-api): drop invalid null compare and keep display overrides languageRefId no longer compares an object to null, inactive languages are ignored, display overrides still apply when the language store is missing, and UoM Convert loads both units together. --- modules/auth/web/stores/auth/actions.test.ts | 31 +++++++++++++++++++ modules/auth/web/stores/auth/actions.ts | 11 +++++-- .../stores/auth/language_preference.test.ts | 7 ++++- .../web/stores/auth/language_preference.ts | 14 ++++----- modules/base/service/models/_uom_convert.ts | 3 +- .../base/service/tests/uom_convert.test.ts | 7 +++++ 6 files changed, 61 insertions(+), 12 deletions(-) diff --git a/modules/auth/web/stores/auth/actions.test.ts b/modules/auth/web/stores/auth/actions.test.ts index aeefd1df1..b6850ac51 100644 --- a/modules/auth/web/stores/auth/actions.test.ts +++ b/modules/auth/web/stores/auth/actions.test.ts @@ -465,6 +465,37 @@ test('loadUser: swallows i18n apply failures', async () => { expect((mockState.currentUser.value as any)?.Id).toBe('usr_1'); }); +test('loadUser: still applies display overrides when the language store is missing', async () => { + const mockState = buildMockState(); + const mockHelpers = buildMockHelpers(); + mockState.identity.value = { userId: 'usr_1' } as any; + mockState._recorders.Browse.resolve({ + Id: 'usr_1', + LanguageId: 'lang_zh', + Preferences: { display: { dateFormat: 'YYYY-MM-DD' } }, + }); + const setDisplayOverrides = makeFn(); + setDisplayOverrides.returnValue(undefined); + + const actions = defineAuthActions(mockState as any, mockHelpers as any, { + isClient: true, + createLanguageStore: () => { + throw new Error('registry down'); + }, + importI18nStore: async () => ({ + useI18nStore: () => ({ + setUiKey: async () => undefined, + setDisplayOverrides: setDisplayOverrides.fn, + }), + langToUiKey: (lang: string) => lang, + }), + }); + + const ok = await actions.loadUser(true); + expect(ok).toBe(true); + expect(setDisplayOverrides.calls[0].args[0]).toEqual({ dateFormat: 'YYYY-MM-DD' }); +}); + test('loadUser: exercises default i18nStore and Language registry imports', async () => { const mockState = buildMockState(); const mockHelpers = buildMockHelpers(); diff --git a/modules/auth/web/stores/auth/actions.ts b/modules/auth/web/stores/auth/actions.ts index 09a246d1a..d11ca33e8 100644 --- a/modules/auth/web/stores/auth/actions.ts +++ b/modules/auth/web/stores/auth/actions.ts @@ -423,11 +423,18 @@ export function defineAuthActions(state: AuthState, helpers: AuthHelpers, deps?: : import('@/web/web/stores/i18nStore')); const { applyUserLanguagePreference } = await import('./language_preference'); const i18nStore = useI18nStore(); - const languageStore = await getLanguageStore(); + let browseLanguage: (id: string, fields: string[]) => Promise<{ Code?: string; IsActive?: boolean } | null | undefined> = + async () => null; + try { + const languageStore = await getLanguageStore(); + browseLanguage = (id, fields) => languageStore.Browse(id, fields); + } catch { + // Language registry unavailable; display overrides still apply below. + } await applyUserLanguagePreference({ languageId: (user as any)?.LanguageId, displayOverrides: (user as any)?.Preferences?.display ?? null, - browseLanguage: (id, fields) => languageStore.Browse(id, fields), + browseLanguage, setUiKey: key => i18nStore.setUiKey(key), setDisplayOverrides: overrides => i18nStore.setDisplayOverrides(overrides as any), langToUiKey, diff --git a/modules/auth/web/stores/auth/language_preference.test.ts b/modules/auth/web/stores/auth/language_preference.test.ts index ab5e15d3e..28dd48388 100644 --- a/modules/auth/web/stores/auth/language_preference.test.ts +++ b/modules/auth/web/stores/auth/language_preference.test.ts @@ -16,7 +16,7 @@ test('terminologyCodeFromLanguageId: empty id skips Browse', async () => { test('terminologyCodeFromLanguageId: returns trimmed Code from Browse', async () => { const code = await terminologyCodeFromLanguageId(' lang_1 ', async (id, fields) => { expect(id).toBe('lang_1'); - expect(fields).toEqual(['Code']); + expect(fields).toEqual(['Code', 'IsActive']); return { Code: ' en_US ' }; }); expect(code).toBe('en_US'); @@ -40,6 +40,11 @@ test('terminologyCodeFromLanguageId: empty object skips Browse', async () => { expect(browsed).toBe(false); }); +test('terminologyCodeFromLanguageId: inactive language yields empty code', async () => { + const code = await terminologyCodeFromLanguageId('lang_off', async () => ({ Code: 'zh_CN', IsActive: false })); + expect(code).toBe(''); +}); + test('applyUserLanguagePreference: sets display overrides before language resolution', async () => { const order: string[] = []; await applyUserLanguagePreference({ diff --git a/modules/auth/web/stores/auth/language_preference.ts b/modules/auth/web/stores/auth/language_preference.ts index 3fad079c3..67be0bef0 100644 --- a/modules/auth/web/stores/auth/language_preference.ts +++ b/modules/auth/web/stores/auth/language_preference.ts @@ -8,20 +8,20 @@ */ export async function terminologyCodeFromLanguageId( languageId: unknown, - browseLanguage: (id: string, fields: string[]) => Promise<{ Code?: string } | null | undefined> + browseLanguage: (id: string, fields: string[]) => Promise<{ Code?: string; IsActive?: boolean } | null | undefined> ): Promise { const id = languageRefId(languageId); if (!id) return ''; - const row = await browseLanguage(id, ['Code']); + const row = await browseLanguage(id, ['Code', 'IsActive']); + if (row?.IsActive === false) return ''; return String(row?.Code || '').trim(); } function languageRefId(value: unknown): string { if (value == null || Array.isArray(value)) return ''; - const raw = - typeof value === 'object' && value !== null && 'Id' in value ? (value as { Id?: unknown }).Id : value; - const id = String(raw ?? '').trim(); - return id && id !== '[object Object]' ? id : ''; + if (typeof value !== 'object') return String(value).trim(); + if (!('Id' in value)) return ''; + return String((value as { Id?: unknown }).Id ?? '').trim(); } /** @@ -32,7 +32,7 @@ function languageRefId(value: unknown): string { export async function applyUserLanguagePreference(opts: { languageId: unknown; displayOverrides?: unknown; - browseLanguage: (id: string, fields: string[]) => Promise<{ Code?: string } | null | undefined>; + browseLanguage: (id: string, fields: string[]) => Promise<{ Code?: string; IsActive?: boolean } | null | undefined>; setUiKey: (uiKey: string) => Promise | unknown; setDisplayOverrides: (overrides: unknown) => void; langToUiKey: (terminologyLang: string) => string; diff --git a/modules/base/service/models/_uom_convert.ts b/modules/base/service/models/_uom_convert.ts index 6d7512308..b04195017 100644 --- a/modules/base/service/models/_uom_convert.ts +++ b/modules/base/service/models/_uom_convert.ts @@ -62,8 +62,7 @@ export async function convertUoM(UoMModel: UoMOps, params: UoMConvertParams | un return row as UoMRow; }; - const from = await load(fromUoMId, 'FromUoMId'); - const to = await load(toUoMId, 'ToUoMId'); + const [from, to] = await Promise.all([load(fromUoMId, 'FromUoMId'), load(toUoMId, 'ToUoMId')]); const fromCategory = String(resolveModelRefId(from, 'CategoryId') ?? '').trim(); const toCategory = String(resolveModelRefId(to, 'CategoryId') ?? '').trim(); if (!fromCategory || !toCategory || fromCategory !== toCategory) { diff --git a/modules/base/service/tests/uom_convert.test.ts b/modules/base/service/tests/uom_convert.test.ts index a84fd4c24..b6c716841 100644 --- a/modules/base/service/tests/uom_convert.test.ts +++ b/modules/base/service/tests/uom_convert.test.ts @@ -46,6 +46,13 @@ test('base.UoM Convert scales by factor within one category and rounds to the ta }); expect(result.Amount.eq(new Decimal('2000'))).toBe(true); + const decimalAmount = await UoM.Convert({ + Amount: new Decimal('2'), + FromUoMId: kilogramId, + ToUoMId: gramId, + }); + expect(decimalAmount.Amount.eq(new Decimal('2000'))).toBe(true); + const back = await UoM.Convert({ Amount: '15', FromUoMId: gramId,