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/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..453d31650 --- /dev/null +++ b/modules/auth/service/hook/post_upgrade.test.ts @@ -0,0 +1,140 @@ +// 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: 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: 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; + (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; + } +}); + +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 new file mode 100644 index 000000000..cf9498005 --- /dev/null +++ b/modules/auth/service/hook/post_upgrade.ts @@ -0,0 +1,45 @@ +// 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).trim().toLowerCase(); + let sql = ''; + if (dialect === 'sqlite') { + sql = `UPDATE auth_user + SET language_id = ( + 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 + AND trim(language) <> ''`; + } else if (dialect === 'postgres' || dialect === 'mysql') { + sql = `UPDATE auth_user + SET language_id = ( + SELECT id FROM base_language + WHERE code = trim(auth_user.language) AND is_active = true + LIMIT 1 + ) + WHERE COALESCE(language_id, '') = '' + AND COALESCE(trim(language), '') <> ''`; + } + if (!sql) return; + try { + await db.execute(sql, '[]'); + } 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/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..50e358763 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 = normalizeRefId(user?.LanguageId) || ''; + 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..500a31041 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' }); @@ -83,6 +84,7 @@ const displayName = computed(() => { }); const languageCode = ref(''); +const savedLanguageCode = ref(''); const timezone = ref(null); const languageFromSession = ref(false); const timezoneFromBrowser = ref(false); @@ -121,8 +123,12 @@ async function loadTimezoneOptions() { timezoneOptions.value = []; } -function syncLanguageFromUser() { - const resolved = resolvePreferenceLanguage(currentUser.value?.Language, i18nStore.terminologyLang); +async function syncLanguageFromUser() { + const code = await resolveLanguageCodeFromId(currentUser.value?.LanguageId, (id, fields) => + (languageStore as any).Browse(id, fields) + ); + savedLanguageCode.value = code; + const resolved = resolvePreferenceLanguage(code || null, i18nStore.terminologyLang); languageCode.value = resolved.code; languageFromSession.value = resolved.fromSession; } @@ -147,7 +153,7 @@ async function openAndLoad() { // Fall back to whatever is already in auth state / identity. } } - syncLanguageFromUser(); + await syncLanguageFromUser(); await Promise.all([loadLanguageOptions(), loadTimezoneOptions()]); applyTimezoneFromUserOrBrowser(); } @@ -162,7 +168,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 +209,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/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..52229c442 --- /dev/null +++ b/modules/auth/web/components/preferences/preferences_language.ts @@ -0,0 +1,19 @@ +// 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. + */ +export async function resolveLanguageCodeFromId( + languageId: unknown, + browseLanguage: (id: string, fields: string[]) => Promise<{ Code?: string } | null | undefined> +): Promise { + try { + return await terminologyCodeFromLanguageId(languageId, browseLanguage); + } catch { + return ''; + } +} diff --git a/modules/auth/web/pages/Login.vue b/modules/auth/web/pages/Login.vue index f4863343c..f41b7dcd5 100644 --- a/modules/auth/web/pages/Login.vue +++ b/modules/auth/web/pages/Login.vue @@ -53,7 +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 { runLoginAuthReady } from './login_auth_ready'; const { _t } = createTranslate('auth', { scope: 'web/pages/Login' }); @@ -130,19 +129,6 @@ async function handleLogin() { await authStore.login(form.username, form.password, '', '', form.rememberMe); - // Apply persisted User.Language when present (terminology lang → UI locale). - try { - await authStore.loadUser(true); - const preferredLang = String((authStore.currentUser as any)?.Language || '').trim(); - const i18nStore = useI18nStore(); - if (preferredLang) { - await i18nStore.setUiKey(langToUiKey(preferredLang)); - } - i18nStore.setDisplayOverrides((authStore.currentUser as any)?.Preferences?.display ?? null); - } catch { - // Preference apply is best-effort; login already succeeded. - } - handleRedirect(); } catch (err) { if (err instanceof ChoysumError) { diff --git a/modules/auth/web/stores/auth/actions.test.ts b/modules/auth/web/stores/auth/actions.test.ts index 5bb99e95f..b6850ac51 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,205 @@ 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 setUiKey = makeFn(); + setUiKey.resolve(undefined); + const setDisplayOverrides = makeFn(); + setDisplayOverrides.returnValue(undefined); + + const actions = defineAuthActions(mockState as any, mockHelpers as any, { + isClient: true, + createLanguageStore: () => ({ + 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); + 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'); + 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('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(); + 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(); + 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); +}); + +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 93b9265f4..d11ca33e8 100644 --- a/modules/auth/web/stores/auth/actions.ts +++ b/modules/auth/web/stores/auth/actions.ts @@ -13,10 +13,23 @@ 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>; + }; + /** 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; + }>; }; /** @@ -30,6 +43,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. */ @@ -392,17 +414,31 @@ 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 { useI18nStore, langToUiKey } = await (deps?.importI18nStore + ? deps.importI18nStore() + : import('@/web/web/stores/i18nStore')); + const { applyUserLanguagePreference } = await import('./language_preference'); const i18nStore = useI18nStore(); - const preferredLang = String((user as any)?.Language || '').trim(); - if (preferredLang) { - await i18nStore.setUiKey(langToUiKey(preferredLang)); + 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. } - i18nStore.setDisplayOverrides((user as any)?.Preferences?.display ?? null); + await applyUserLanguagePreference({ + languageId: (user as any)?.LanguageId, + displayOverrides: (user as any)?.Preferences?.display ?? null, + browseLanguage, + setUiKey: key => i18nStore.setUiKey(key), + setDisplayOverrides: overrides => i18nStore.setDisplayOverrides(overrides as any), + langToUiKey, + }); } catch { // Best-effort; auth must not fail because of i18n wiring. } @@ -496,7 +532,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,9 +547,22 @@ export function defineAuthActions(state: AuthState, helpers: AuthHelpers, deps?: if (!userId) { return; } - await state.userStore.UpdateById(userId, { Language: terminologyLang } as any, ['Id', 'Language'] as any); + 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) { - (state.currentUser.value as any).Language = terminologyLang; + (state.currentUser.value as any).LanguageId = languageId; } // Sync JWT metadata.language after preference write. await refreshTokenImpl(true); 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..28dd48388 --- /dev/null +++ b/modules/auth/web/stores/auth/language_preference.test.ts @@ -0,0 +1,104 @@ +// 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', 'IsActive']); + return { Code: ' en_US ' }; + }); + 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('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({ + languageId: 'lang_zh', + displayOverrides: { dateFormat: 'YYYY-MM-DD' }, + browseLanguage: async () => { + order.push('browse'); + return { Code: 'zh_CN' }; + }, + setUiKey: async key => { + order.push(`ui:${key}`); + }, + setDisplayOverrides: value => { + order.push(`overrides:${JSON.stringify(value)}`); + }, + langToUiKey: lang => `ui:${lang}`, + }); + 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 () => { + 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..67be0bef0 --- /dev/null +++ b/modules/auth/web/stores/auth/language_preference.ts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +/** + * 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; IsActive?: boolean } | null | undefined> +): Promise { + const id = languageRefId(languageId); + if (!id) return ''; + 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 ''; + if (typeof value !== 'object') return String(value).trim(); + if (!('Id' in value)) return ''; + return String((value as { Id?: unknown }).Id ?? '').trim(); +} + +/** + * 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: { + languageId: unknown; + displayOverrides?: unknown; + 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; +}): Promise { + opts.setDisplayOverrides(opts.displayOverrides ?? null); + const preferredLang = await terminologyCodeFromLanguageId(opts.languageId, opts.browseLanguage); + if (preferredLang) { + await opts.setUiKey(opts.langToUiKey(preferredLang)); + } +} 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 - + = 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 new file mode 100644 index 000000000..b04195017 --- /dev/null +++ b/modules/base/service/models/_uom_convert.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// 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; + 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); +} + +/** + * 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 => { + 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; + }; + + 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) { + invalid(_t('UoMs must belong to the same category', { 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/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..b6c716841 --- /dev/null +++ b/modules/base/service/tests/uom_convert.test.ts @@ -0,0 +1,153 @@ +// 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 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, + ToUoMId: kilogramId, + }); + 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(); + 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'); +}); + +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'); +}); + +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'); +}); + +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/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/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..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:81 +#: service/models/partner.ts:96 msgctxt "partner.model.Partner.fields" msgid "Active" msgstr "" -#: service/models/partner.ts:55 +#: service/models/partner.ts:70 msgctxt "partner.model.Partner.fields" msgid "Code" msgstr "" -#: service/models/partner.ts:71 +#: service/models/partner.ts:86 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: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:197 +#: service/models/partner.ts:212 msgctxt "partner.model.Partner.fields" msgid "Contacts and Addresses" msgstr "" -#: service/models/partner.ts:153 +#: service/models/partner.ts:168 msgctxt "partner.model.Partner.fields" msgid "Country" msgstr "" -#: service/models/partner.ts:104 +#: service/models/partner.ts:119 msgctxt "partner.model.Partner.fields" msgid "Customer Rank" msgstr "" -#: service/models/partner.ts:225 +#: service/models/partner.ts:240 msgctxt "partner.model.Partner.fields" msgid "Default Billing Address" msgstr "" -#: service/models/partner.ts:206 +#: service/models/partner.ts:221 msgctxt "partner.model.Partner.fields" msgid "Default Contact" msgstr "" -#: service/models/partner.ts:142 +#: service/models/partner.ts:157 msgctxt "partner.model.Partner.fields" msgid "Default Currency" msgstr "" -#: service/models/partner.ts:131 +#: service/models/partner.ts:146 msgctxt "partner.model.Partner.fields" msgid "Default Language" msgstr "" -#: service/models/partner.ts:244 +#: service/models/partner.ts:259 msgctxt "partner.model.Partner.fields" msgid "Default Shipping Address" msgstr "" -#: service/models/partner.ts:171 +#: service/models/partner.ts:186 msgctxt "partner.model.Partner.fields" msgid "Email" msgstr "" -#: service/models/partner.ts:162 +#: service/models/partner.ts:177 msgctxt "partner.model.Partner.fields" msgid "External Reference" msgstr "" -#: service/models/partner.ts:189 +#: service/models/partner.ts:204 msgctxt "partner.model.Partner.fields" msgid "Mobile" msgstr "" -#: service/models/partner.ts:44 +#: service/models/partner.ts:59 msgctxt "partner.model.Partner.fields" msgid "Name" msgstr "" -#: service/models/partner.ts:105 +#: 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:118 +#: 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:273 +#: service/models/partner.ts:288 msgctxt "partner.model.Partner.fields" msgid "Notes" msgstr "" -#: service/models/partner.ts:91 +#: service/models/partner.ts:106 msgctxt "partner.model.Partner.fields" msgid "Organization" msgstr "" -#: service/models/partner.ts:180 +#: service/models/partner.ts:195 msgctxt "partner.model.Partner.fields" msgid "Phone" msgstr "" -#: service/models/partner.ts:264 +#: service/models/partner.ts:279 msgctxt "partner.model.Partner.fields" msgid "Sequence" msgstr "" -#: service/models/partner.ts:117 +#: service/models/partner.ts:132 msgctxt "partner.model.Partner.fields" msgid "Supplier Rank" msgstr "" -#: service/models/partner.ts:92 +#: 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:56 +#: service/models/partner.ts:71 msgctxt "partner.model.Partner.fields" msgid "Unique within the company; stored in uppercase." msgstr "" @@ -224,22 +224,33 @@ 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:336 +#: service/models/partner.ts:35 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: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" 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 +266,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 +351,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 +376,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 +391,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 +436,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 +516,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..5b223a84b 100644 --- a/modules/partner/i18n/zh_CN.po +++ b/modules/partner/i18n/zh_CN.po @@ -1,262 +1,272 @@ 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:96 msgctxt "partner.model.Partner.fields" msgid "Active" msgstr "启用" -#: service/models/partner.ts:51 +#: service/models/partner.ts:70 msgctxt "partner.model.Partner.fields" msgid "Code" msgstr "编码" -#: service/models/partner.ts:67 +#: service/models/partner.ts:86 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: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:193 +#: service/models/partner.ts:212 msgctxt "partner.model.Partner.fields" msgid "Contacts and Addresses" msgstr "联系人与地址" -#: service/models/partner.ts:149 +#: service/models/partner.ts:168 msgctxt "partner.model.Partner.fields" msgid "Country" msgstr "国家" -#: service/models/partner.ts:100 +#: service/models/partner.ts:119 msgctxt "partner.model.Partner.fields" msgid "Customer Rank" msgstr "客户等级" -#: service/models/partner.ts:221 +#: service/models/partner.ts:240 msgctxt "partner.model.Partner.fields" msgid "Default Billing Address" msgstr "默认开票地址" -#: service/models/partner.ts:202 +#: service/models/partner.ts:221 msgctxt "partner.model.Partner.fields" msgid "Default Contact" msgstr "默认联系人" -#: service/models/partner.ts:138 +#: service/models/partner.ts:157 msgctxt "partner.model.Partner.fields" msgid "Default Currency" msgstr "默认币种" -#: service/models/partner.ts:127 +#: service/models/partner.ts:146 msgctxt "partner.model.Partner.fields" msgid "Default Language" msgstr "默认语言" -#: service/models/partner.ts:240 +#: service/models/partner.ts:259 msgctxt "partner.model.Partner.fields" msgid "Default Shipping Address" msgstr "默认收货地址" -#: service/models/partner.ts:167 +#: service/models/partner.ts:186 msgctxt "partner.model.Partner.fields" msgid "Email" msgstr "邮箱" -#: service/models/partner.ts:158 +#: service/models/partner.ts:177 msgctxt "partner.model.Partner.fields" msgid "External Reference" msgstr "外部参考" -#: service/models/partner.ts:185 +#: service/models/partner.ts:204 msgctxt "partner.model.Partner.fields" msgid "Mobile" msgstr "手机" -#: service/models/partner.ts:40 +#: service/models/partner.ts:59 msgctxt "partner.model.Partner.fields" msgid "Name" msgstr "名称" -#: service/models/partner.ts:101 +#: 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:114 +#: 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:269 +#: service/models/partner.ts:288 msgctxt "partner.model.Partner.fields" msgid "Notes" msgstr "备注" -#: service/models/partner.ts:87 +#: service/models/partner.ts:106 msgctxt "partner.model.Partner.fields" msgid "Organization" msgstr "组织主体" -#: service/models/partner.ts:176 +#: service/models/partner.ts:195 msgctxt "partner.model.Partner.fields" msgid "Phone" msgstr "电话" -#: service/models/partner.ts:260 +#: service/models/partner.ts:279 msgctxt "partner.model.Partner.fields" msgid "Sequence" msgstr "排序" -#: service/models/partner.ts:113 +#: service/models/partner.ts:132 msgctxt "partner.model.Partner.fields" msgid "Supplier Rank" msgstr "供应商等级" -#: service/models/partner.ts:88 +#: 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:52 +#: service/models/partner.ts:71 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:336 service/models/partner.ts:35 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:348 msgctxt "service/models/partner" msgid "Partner Code must be unique within the company" msgstr "伙伴编码在同一公司内必须唯一" -#: service/models/partner_contact.ts:187 +#: 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" 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 +316,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 +341,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 +366,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 +381,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 +391,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 +406,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 +416,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 +436,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 +461,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 +486,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 +506,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 +574,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 f020107fa..57f400803 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,31 @@ import type Currency from '@/base/service/models/currency'; import type Language from '@/base/service/models/language'; import PartnerContact from './partner_contact'; +/** Matches Partner.Code varchar size. */ +const CODE_MAX_LENGTH = 40; + +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, CODE_MAX_LENGTH); + return seed || 'P'; +} + /** * Company-scoped business partner master record with derived default contacts and addresses. * @@ -349,4 +377,91 @@ 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); + 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) { + // Uniqueness errors are localized; re-query decides whether another writer won. + 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; + } + } + + /** + * Create a partner from a display name (relation typeahead). + * Code is derived from the name and disambiguated within the session company. + * Allocation retries when the candidate code is already taken (locale-independent). + */ + static async NameCreate> | undefined = undefined>( + this: C, + name: string, + values?: Partial>>, + options?: { returnFields?: F } + ): Promise { + const partnerValues = (values || {}) as Partial>; + 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); + const explicitCode = String(partnerValues.Code || '').trim().toUpperCase(); + 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, CODE_MAX_LENGTH - 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) throw err; + // Locale-independent: retry only when this code is already taken. + 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; + } } 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..688aab37e --- /dev/null +++ b/modules/partner/service/tests/find_or_create.test.ts @@ -0,0 +1,73 @@ +// 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)); +}); + +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 fb897e52b..1078630bf 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,40 @@ export default class PartnerIdentifier extends BaseModel { await PartnerIdentifier.validateEntity(this as unknown as Record, currentId); } + + /** + * 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(); + 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], + ['IsActive', '=', true], + ], + }, + { 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 }; + const partnerId = normalizeRefId(row?.PartnerId) || ''; + if (!partnerId) return { Found: false }; + return { + Found: true, + PartnerIdentifierId: identifierId, + 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 new file mode 100644 index 000000000..70e918ec9 --- /dev/null +++ b/modules/partner_commercial/service/tests/partner_identifier_lookup.test.ts @@ -0,0 +1,85 @@ +// 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); +}); + +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); +}); 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 }); }