diff --git a/src/modules/customers/pages/CustomerDetailView.test.ts b/src/modules/customers/pages/CustomerDetailView.test.ts index cdc63615..045e4750 100644 --- a/src/modules/customers/pages/CustomerDetailView.test.ts +++ b/src/modules/customers/pages/CustomerDetailView.test.ts @@ -117,6 +117,9 @@ describe('CustomerDetailView', () => { expect(document.querySelector('.overview-strip')).toBeNull() expect(document.body.textContent).toContain('merchant_admin') expect(document.body.textContent).toContain('企业管理员') + expect(document.body.textContent).toContain('公司联系电话') + expect(document.body.textContent).toContain('登录手机号') + expect(document.body.textContent).toContain('修改会影响登录') expect(listPlatformTenantMembers).toHaveBeenCalledWith(tenant.id) expect(document.body.textContent).toContain('已替换') diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts index 0d41c3cc..1007ec75 100644 --- a/src/modules/customers/pages/CustomersView.test.ts +++ b/src/modules/customers/pages/CustomersView.test.ts @@ -1,4 +1,4 @@ -import ElementPlus from 'element-plus' +import ElementPlus, { ElMessageBox } from 'element-plus' import { createPinia, setActivePinia } from 'pinia' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { approveEnterpriseReview, archivePlatformTenant, + createPlatformTenant, downloadTenantQualification, createPlatformTenantImpersonation, getPlatformTenant, @@ -15,6 +16,7 @@ import { listTenantQualifications, rejectEnterpriseReview, resetPlatformTenantOwnerPassword, + submitPlatformTenantReview, updatePlatformTenant, uploadPlatformTenantQualification, uploadPlatformTenantRequiredQualificationPackage, @@ -104,6 +106,12 @@ function tableButtonByText(text: string) { .find((button) => button.textContent?.trim() === text) } +function selectQualificationFile(index: number, file: File) { + const input = document.querySelectorAll('.qualification-file input[type="file"]')[index] + Object.defineProperty(input, 'files', { configurable: true, value: [file] }) + input.dispatchEvent(new Event('change', { bubbles: true })) +} + async function clickMoreAction(text: string) { document.querySelector('.el-table__body button[aria-label^="更多客户操作:"]')?.click() await flushPromises() @@ -139,6 +147,10 @@ describe('CustomersView', () => { vi.mocked(getPlatformTenant).mockResolvedValue(fullTenant) vi.mocked(listTenantQualifications).mockResolvedValue([]) vi.mocked(listEnterpriseReviews).mockResolvedValue([]) + vi.mocked(createPlatformTenant).mockResolvedValue({ + tenant: fullTenant, administratorUsername: 'merchant_owner', temporaryPassword: 'Temporary#2026', + }) + vi.mocked(submitPlatformTenantReview).mockResolvedValue({ ...fullTenant, status: 'PENDING' }) vi.mocked(updatePlatformTenant).mockResolvedValue(fullTenant) vi.mocked(uploadPlatformTenantQualification).mockResolvedValue({ qualificationId: 'qualification-1', reviewId: null, tenantStatus: 'PROFILE_REQUIRED', documentUrl: '/files/license.png', @@ -187,7 +199,7 @@ describe('CustomersView', () => { await flushPromises() expect(getPlatformTenant).toHaveBeenCalledWith(fullTenant.id) - const phone = document.querySelector('input[placeholder="请输入手机号或固定电话"]') + const phone = document.querySelector('input[placeholder="请输入公司联系电话"]') const email = document.querySelector('input[placeholder="如:contact@example.com"]') expect(phone?.value).toBe(fullTenant.contactPhone) expect(email?.value).toBe(fullTenant.contactEmail) @@ -212,19 +224,83 @@ describe('CustomersView', () => { expect(preview?.src).toBe('blob:qualification-preview') }) - it('marks only the business license and legal-person ID as required qualifications', async () => { + it('marks only the business license as a required file when creating a customer', async () => { + const auth = useAuthStore() + auth.permissions = [...auth.permissions, 'platform:customer:create'] + useAccessPreviewStore().initializeAfterLogin() mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() - tableButtonByText('编辑')?.click() + ;[...document.querySelectorAll('button')] + .find((button) => button.textContent?.trim() === '新增客户')?.click() await flushPromises() const labels = [...document.querySelectorAll('.qualification-label')] expect(labels.map((label) => label.textContent?.replace(/\s/g, ''))).toEqual([ '*营业执照', - '*法人身份证', + '法人身份证选填', '货代资质证书选填', '银行开户许可证选填', ]) + expect(document.body.textContent).toContain('营业执照和18位统一社会信用代码为必填业务项') + }) + + it('creates a customer with a business license and no legal-person ID', async () => { + const auth = useAuthStore() + auth.permissions = [...auth.permissions, 'platform:customer:create'] + useAccessPreviewStore().initializeAfterLogin() + const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + ;[...document.querySelectorAll('button')] + .find((button) => button.textContent?.trim() === '新增客户')?.click() + await flushPromises() + + await wrapper.get('input[placeholder="请输入工商登记公司名称"]').setValue(fullTenant.tenantName) + await wrapper.get('input[placeholder="请输入营业执照上的企业名称"]').setValue(fullTenant.legalName) + await wrapper.get('input[placeholder="请输入法定代表人姓名"]').setValue(fullTenant.legalRepresentative) + await wrapper.get('input[placeholder="请输入主要联系人姓名"]').setValue(fullTenant.contactName) + await wrapper.get('input[placeholder="请输入公司联系电话"]').setValue(fullTenant.contactPhone) + await wrapper.get('input[placeholder="请输入18位大写字母或数字"]').setValue(fullTenant.unifiedSocialCreditCode) + const licenseFile = new File(['license'], 'license.png', { type: 'image/png' }) + selectQualificationFile(0, licenseFile) + await flushPromises() + ;[...document.querySelectorAll('button')] + .find((button) => button.textContent?.includes('保存资料和资质'))?.click() + await flushPromises() + + expect(createPlatformTenant).toHaveBeenCalledWith(expect.objectContaining({ + tenantName: fullTenant.tenantName, + contactPhone: fullTenant.contactPhone, + })) + expect(createPlatformTenant).toHaveBeenCalledWith(expect.not.objectContaining({ unifiedSocialCreditCode: expect.anything() })) + expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledWith( + fullTenant.id, licenseFile, null, fullTenant.unifiedSocialCreditCode, false, false, + ) + }) + + it('keeps the company contact phone editable without changing a member login phone', async () => { + const activeTenant = { ...fullTenant, status: 'ACTIVE' as const } + vi.mocked(listPlatformTenants).mockResolvedValue({ records: [activeTenant], page: 1, size: 10, total: 1, pages: 1 }) + vi.mocked(getPlatformTenant).mockResolvedValue(activeTenant) + vi.mocked(updatePlatformTenant).mockResolvedValue(activeTenant) + vi.mocked(listTenantQualifications).mockResolvedValue([{ + id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE', + certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'APPROVED', reviewComment: null, validTo: null, + }]) + const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + tableButtonByText('编辑')?.click() + await flushPromises() + + const phone = wrapper.get('input[placeholder="请输入公司联系电话"]') + expect(phone.attributes('disabled')).toBeUndefined() + await phone.setValue('0755-12345678') + ;[...document.querySelectorAll('button')] + .find((button) => button.textContent?.includes('保存资料和资质'))?.click() + await flushPromises() + + expect(updatePlatformTenant).toHaveBeenCalledWith(fullTenant.id, expect.objectContaining({ contactPhone: '0755-12345678' })) + expect(document.body.textContent).toContain('不会修改企业管理员登录手机号') + expect(customersSource).not.toContain('联系电话(登录用户名)') }) it('does not count rejected or superseded history as a current qualification when resubmitting', async () => { @@ -272,11 +348,11 @@ describe('CustomersView', () => { await flushPromises() expect(getPlatformTenant).toHaveBeenCalledWith(fullTenant.id) - expect(document.querySelector('input[placeholder="请输入手机号或固定电话"]')?.value).toBe(fullTenant.contactPhone) + expect(document.querySelector('input[placeholder="请输入公司联系电话"]')?.value).toBe(fullTenant.contactPhone) expect(routerReplace).toHaveBeenCalledWith({ name: 'customers' }) }) - it('saves both required files and the credit code through one package action', async () => { + it('saves the business license, credit code and optional legal-person ID through one package action', async () => { vi.mocked(listTenantQualifications).mockResolvedValue([{ id: 'qualification-legal', tenantId: fullTenant.id, qualificationType: 'LEGAL_PERSON_ID', certificateNo: null, documentUrl: '/files/legal-person.png', status: 'PENDING', reviewComment: null, validTo: null, @@ -288,13 +364,8 @@ describe('CustomersView', () => { const licenseFile = new File(['license'], 'license.png', { type: 'image/png' }) const legalPersonFile = new File(['legal-person'], 'legal-person.png', { type: 'image/png' }) - const qualificationInputs = document.querySelectorAll('.qualification-file input[type="file"]') - const licenseInput = qualificationInputs[0] - const legalPersonInput = qualificationInputs[1] - Object.defineProperty(licenseInput, 'files', { configurable: true, value: [licenseFile] }) - licenseInput?.dispatchEvent(new Event('change', { bubbles: true })) - Object.defineProperty(legalPersonInput, 'files', { configurable: true, value: [legalPersonFile] }) - legalPersonInput?.dispatchEvent(new Event('change', { bubbles: true })) + selectQualificationFile(0, licenseFile) + selectQualificationFile(1, legalPersonFile) await flushPromises() const buttons = [...document.querySelectorAll('button')] @@ -314,6 +385,101 @@ describe('CustomersView', () => { expect(uploadPlatformTenantQualification).not.toHaveBeenCalled() }) + it('replaces only the business license while preserving an existing legal-person ID', async () => { + vi.mocked(listTenantQualifications).mockResolvedValue([ + { id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE', certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'PENDING', reviewComment: null, validTo: null }, + { id: 'qualification-legal', tenantId: fullTenant.id, qualificationType: 'LEGAL_PERSON_ID', certificateNo: null, documentUrl: '/files/legal-person.png', status: 'PENDING', reviewComment: null, validTo: null }, + ]) + vi.mocked(uploadPlatformTenantRequiredQualificationPackage).mockResolvedValueOnce({ + businessLicenseQualificationId: 'qualification-business-new', legalPersonIdQualificationId: null, + tenantStatus: 'PROFILE_REQUIRED', businessLicenseDocumentUrl: '/files/license-new.png', legalPersonIdDocumentUrl: null, + }) + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + tableButtonByText('编辑')?.click() + await flushPromises() + + const licenseFile = new File(['license-new'], 'license-new.png', { type: 'image/png' }) + selectQualificationFile(0, licenseFile) + await flushPromises() + ;[...document.querySelectorAll('button')] + .find((button) => button.textContent?.includes('保存资料和资质'))?.click() + await flushPromises() + + expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledWith( + fullTenant.id, licenseFile, null, fullTenant.unifiedSocialCreditCode, true, false, + ) + expect(uploadPlatformTenantQualification).not.toHaveBeenCalled() + expect(document.body.textContent).toContain('法人身份证可按需补充') + }) + + it('uploads an optional legal-person ID independently when the required business license already exists', async () => { + vi.mocked(listTenantQualifications).mockResolvedValue([{ + id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE', + certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'PENDING', reviewComment: null, validTo: null, + }]) + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + tableButtonByText('编辑')?.click() + await flushPromises() + + const legalPersonFile = new File(['legal-person'], 'legal-person.png', { type: 'image/png' }) + selectQualificationFile(1, legalPersonFile) + await flushPromises() + ;[...document.querySelectorAll('button')] + .find((button) => button.textContent?.includes('保存资料和资质'))?.click() + await flushPromises() + + expect(uploadPlatformTenantRequiredQualificationPackage).not.toHaveBeenCalled() + expect(uploadPlatformTenantQualification).toHaveBeenCalledWith( + fullTenant.id, 'LEGAL_PERSON_ID', legalPersonFile, undefined, undefined, false, + ) + }) + + it('retains a selected business license after an upload failure so the operator can retry', async () => { + vi.mocked(uploadPlatformTenantRequiredQualificationPackage) + .mockRejectedValueOnce(new Error('upload unavailable')) + .mockResolvedValueOnce({ + businessLicenseQualificationId: 'qualification-business', legalPersonIdQualificationId: null, + tenantStatus: 'PROFILE_REQUIRED', businessLicenseDocumentUrl: '/files/license-new.png', legalPersonIdDocumentUrl: null, + }) + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + tableButtonByText('编辑')?.click() + await flushPromises() + + const licenseFile = new File(['license-new'], 'retry-license.png', { type: 'image/png' }) + selectQualificationFile(0, licenseFile) + await flushPromises() + const saveButton = [...document.querySelectorAll('button')] + .find((button) => button.textContent?.includes('保存资料和资质')) + saveButton?.click() + await flushPromises() + expect(document.body.textContent).toContain('retry-license.png') + + saveButton?.click() + await flushPromises() + expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledTimes(2) + }) + + it('submits a customer for review with a business license and no legal-person ID', async () => { + vi.mocked(listTenantQualifications).mockResolvedValue([{ + id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE', + certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'PENDING', reviewComment: null, validTo: null, + }]) + vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never) + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + tableButtonByText('编辑')?.click() + await flushPromises() + ;[...document.querySelectorAll('button')] + .find((button) => button.textContent?.trim() === '提交审核')?.click() + await flushPromises() + + expect(ElMessageBox.confirm).toHaveBeenCalled() + expect(submitPlatformTenantReview).toHaveBeenCalledWith(fullTenant.id) + }) + it('submits the current filters to the backend export service without loading every customer page', async () => { const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() @@ -398,7 +564,7 @@ describe('CustomersView', () => { .find((button) => button.textContent?.trim() === '审核通过')?.click() await flushPromises() - expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料与必填资质完整、清晰,审核通过') + expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料、营业执照和统一社会信用代码完整、清晰,审核通过(法人身份证为选填项)') expect(rejectEnterpriseReview).not.toHaveBeenCalled() expect(document.body.textContent).toContain('请立即保存以下一次性凭证') expect(document.body.textContent).toContain('merchant_8_owner') @@ -459,7 +625,7 @@ describe('CustomersView', () => { .find((button) => button.textContent?.trim() === '审核通过')?.click() await flushPromises() - expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料与必填资质完整、清晰,审核通过') + expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料、营业执照和统一社会信用代码完整、清晰,审核通过(法人身份证为选填项)') expect(rejectEnterpriseReview).not.toHaveBeenCalled() expect(document.body.textContent).toContain('注册账号继续作为企业管理员') expect(document.body.textContent).not.toContain('请立即保存以下一次性凭证') diff --git a/src/modules/iam/api/platformIam.test.ts b/src/modules/iam/api/platformIam.test.ts index ecaa74c0..932bcf72 100644 --- a/src/modules/iam/api/platformIam.test.ts +++ b/src/modules/iam/api/platformIam.test.ts @@ -377,7 +377,7 @@ describe('platform IAM roles', () => { expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/iam/tenants/8/submit-review', {}) }) - it('uploads both required files and the credit code in one package request', async () => { + it('uploads the business license, credit code and optional legal-person ID in one package request', async () => { const result = { businessLicenseQualificationId: '91', legalPersonIdQualificationId: '92', tenantStatus: 'PROFILE_REQUIRED', businessLicenseDocumentUrl: '/license.pdf', @@ -400,6 +400,26 @@ describe('platform IAM roles', () => { expect(form.get('replaceLegalPersonId')).toBe('true') }) + it('omits the optional legal-person ID parts when only the business license is uploaded', async () => { + const result = { + businessLicenseQualificationId: '91', legalPersonIdQualificationId: null, + tenantStatus: 'PROFILE_REQUIRED', businessLicenseDocumentUrl: '/license.pdf', + legalPersonIdDocumentUrl: null, + } + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: result }) + const businessLicense = new File(['%PDF-1.4'], '营业执照.pdf', { type: 'application/pdf' }) + + await expect(uploadPlatformTenantRequiredQualificationPackage( + '8', businessLicense, null, '91440300MA5ABCDE12', true, false, + )).resolves.toEqual(result) + + const form = vi.mocked(http.post).mock.calls[0]?.[1] as FormData + expect(form.get('businessLicense')).toBe(businessLicense) + expect(form.get('legalPersonId')).toBeNull() + expect(form.get('replaceBusinessLicense')).toBe('true') + expect(form.get('replaceLegalPersonId')).toBeNull() + }) + it('preserves long review identifiers and returns one-time administrator credentials', async () => { const reviewId = '190000000000000001' const approval = {