diff --git a/README.md b/README.md index 75d17edb..cc56a4f7 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ pnpm test 工作台已从一期路由与菜单移除;登录后按当前角色进入第一个有权限的业务页面。一期 Admin 路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限。 -后台菜单仅展示已接入的一期模块;其余平台原型菜单保留为导航配置中的注释,待对应业务契约接入后再恢复展示。企业审核、仓库、头程订单、平台角色、菜单及按钮权限资源均接入真实接口;内部用户使用 IAM 的真实查询和新增接口。运行时不再提供本地演示业务数据或静态角色预览。 +后台菜单仅展示已接入的一期模块;其余平台原型菜单保留为导航配置中的注释,待对应业务契约接入后再恢复展示。企业审核、仓库、头程订单、平台角色、菜单及按钮权限资源均接入真实接口;用户管理通过 IAM 统一维护平台账号和企业租户账号。运行时不再提供本地演示业务数据或静态角色预览。 ## 本地 Gateway 联调 @@ -42,7 +42,7 @@ API_PROXY_TARGET=http://8.163.30.128:9000 仓库管理已接入真实平台 Warehouse API,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给客户新建头程订单选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。本阶段不包含 FBA/海外目的仓、库存、库位或出入库作业。 -头程订单列表、详情、状态历史、内部备注和快递信息已经接入真实平台 Order API;订单不设“已驳回”,后台修改待审核订单后仍保持待审核并写入审计。快递公司已经接入 Warehouse Service 的真实分页查询、新增、编辑和启停接口,按租户与平台权限访问;订单履约接口当前仍按公司名称和快递单号保存。平台往来单位已经接入 Warehouse Service 的真实分页、新增、编辑和启停接口,当前后端类型契约为客户、供应商及客户/供应商双重类型;批量导入接口尚未提供,不模拟导入成功。客户档案尚无正式平台端接口,相应页面明确展示不可用状态。内部用户后端当前只支持查询和新增,编辑、启停、重置密码及角色分配保持不可用。 +头程订单列表、详情、状态历史、内部备注和快递信息已经接入真实平台 Order API;订单不设“已驳回”,后台修改待审核订单后仍保持待审核并写入审计。快递公司已经接入 Warehouse Service 的真实分页查询、详情、新增、编辑、启停、标准模板下载和 XLSX 整批导入接口,按租户与平台权限访问;订单履约接口当前仍按公司名称和快递单号保存。平台往来单位已经接入 Warehouse Service 的真实分页、新增、编辑和启停接口,当前后端类型契约为客户、供应商及客户/供应商双重类型;批量导入接口尚未提供,不模拟导入成功。用户管理已接入统一查询、新增、编辑、所属租户与角色调整、启停、最后登录展示和密码重置接口;账号凭据仍由 Auth Service 独占保存。 真实接口错误会保留后端返回的业务错误码、`requestId` 和 `traceId`。登录、企业审核、平台角色及权限资源的主要失败态支持直接复制追踪标识,便于与后端日志关联。 diff --git a/src/api/platformExpressCompanies.test.ts b/src/api/platformExpressCompanies.test.ts index eb66203f..b904f98c 100644 --- a/src/api/platformExpressCompanies.test.ts +++ b/src/api/platformExpressCompanies.test.ts @@ -3,7 +3,9 @@ import { http } from '@/api/http' import { changePlatformExpressCompanyStatus, createPlatformExpressCompany, + downloadPlatformExpressCompanyImportTemplate, getPlatformExpressCompany, + importPlatformExpressCompanies, listPlatformExpressCompanies, updatePlatformExpressCompany, type SavePlatformExpressCompanyRequest, @@ -71,4 +73,28 @@ describe('platform express company API', () => { { params: { tenantId: 10001 } }, ) }) + + it('downloads the standard template as a blob', async () => { + const template = new Blob(['xlsx'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) + vi.mocked(http.get).mockResolvedValue(template) + + await expect(downloadPlatformExpressCompanyImportTemplate()).resolves.toBe(template) + expect(http.get).toHaveBeenCalledWith('/platform/v1/express-companies/import-template', { responseType: 'blob' }) + }) + + it('uploads the selected XLSX file as multipart form data', async () => { + const result = { fileName: 'express.xlsx', totalRows: 2, importedRows: 2, success: true, errors: [] } + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: result }) + const file = new File(['xlsx'], 'express.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }) + + await expect(importPlatformExpressCompanies(10001, file)).resolves.toEqual(result) + expect(http.post).toHaveBeenCalledOnce() + const [path, body, config] = vi.mocked(http.post).mock.calls[0]! + expect(path).toBe('/platform/v1/express-companies/import') + expect(body).toBeInstanceOf(FormData) + expect((body as FormData).get('file')).toBe(file) + expect(config).toEqual({ params: { tenantId: 10001 } }) + }) }) diff --git a/src/api/platformExpressCompanies.ts b/src/api/platformExpressCompanies.ts index a5d50734..dde4c825 100644 --- a/src/api/platformExpressCompanies.ts +++ b/src/api/platformExpressCompanies.ts @@ -34,6 +34,20 @@ export interface SavePlatformExpressCompanyRequest { remark: string | null } +export interface PlatformExpressCompanyImportError { + row: number + field: string + message: string +} + +export interface PlatformExpressCompanyImportResult { + fileName: string + totalRows: number + importedRows: number + success: boolean + errors: PlatformExpressCompanyImportError[] +} + const basePath = '/platform/v1/express-companies' export async function listPlatformExpressCompanies( @@ -98,3 +112,18 @@ export async function changePlatformExpressCompanyStatus( }) return unwrapApiResponse(response) } + +export async function downloadPlatformExpressCompanyImportTemplate() { + return http.get(`${basePath}/import-template`, { responseType: 'blob' }) +} + +export async function importPlatformExpressCompanies(tenantId: number, file: File) { + const formData = new FormData() + formData.append('file', file) + const response = await http.post, ApiEnvelope>( + `${basePath}/import`, + formData, + { params: { tenantId } }, + ) + return unwrapApiResponse(response) +} diff --git a/src/api/platformIam.test.ts b/src/api/platformIam.test.ts index 07ec63ce..4dec680d 100644 --- a/src/api/platformIam.test.ts +++ b/src/api/platformIam.test.ts @@ -2,28 +2,41 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/api/http' import { assignPlatformInternalUserRoles, + changePlatformManagedUserStatus, changePlatformInternalUserStatus, + changePlatformTenantStatus, changePlatformPermissionStatus, changePlatformRoleStatus, createPlatformPermission, + createPlatformManagedUser, createPlatformRole, + createPlatformTenant, deletePlatformPermission, deletePlatformRole, downloadTenantQualification, listPlatformTenants, + listPlatformTenantOrderCounts, listTenantQualifications, listCurrentPlatformPermissionCodes, listPlatformPermissions, listPlatformRoles, listPlatformInternalUsers, + listPlatformManagedUsers, + getPlatformUserOptions, qualificationFileName, replacePlatformRolePermissions, resetPlatformInternalUserPassword, + resetPlatformManagedUserPassword, + submitPlatformTenantReview, updatePlatformInternalUser, + updatePlatformManagedUser, updatePlatformPermission, updatePlatformRole, + updatePlatformTenant, + uploadPlatformTenantQualification, type PlatformPermissionResource, type PlatformInternalUser, + type PlatformManagedUser, type PlatformRoleResource, } from './platformIam' @@ -73,6 +86,25 @@ const internalUser: PlatformInternalUser = { roleIds: [4000001], } +const managedUser: PlatformManagedUser = { + membershipId: '3000002', + userId: '2000002', + authUserId: '1000002', + username: 'zhangwei', + displayName: '张伟', + phone: '13800138000', + email: 'zhangwei@example.com', + tenantId: '10001', + tenantName: '华远贸易', + memberType: 'EMPLOYEE', + memberNo: 'MEMBER-3000002', + status: 'ACTIVE', + roleIds: ['40001'], + roleNames: ['租户管理员'], + lastLoginAt: '2026-06-01T10:30:00', + protectedAccount: false, +} + describe('platform IAM roles', () => { beforeEach(() => { vi.mocked(http.get).mockReset() @@ -172,6 +204,39 @@ describe('platform IAM roles', () => { expect(http.put).toHaveBeenNthCalledWith(4, `/platform/v1/iam/internal-users/${internalUser.membershipId}/roles`, { roleIds: [4000001] }) }) + it('maintains platform and tenant users through the unified user contract', async () => { + const userOptions = { + tenants: [{ id: '10001', name: '华远贸易', status: 'ACTIVE' }], + roles: [{ id: '40001', tenantId: '10001', name: '租户管理员', status: 'ACTIVE' }], + } + const create = { + username: 'zhangwei', displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com', + tenantId: '10001', roleIds: ['40001'], password: 'Password1', status: 'ACTIVE' as const, + } + const update = { + displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com', + tenantId: '10001', roleIds: ['40001'], + } + vi.mocked(http.get).mockResolvedValueOnce({ code: '200', message: 'success', data: [managedUser] }) + .mockResolvedValueOnce({ code: '200', message: 'success', data: userOptions }) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: managedUser }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: managedUser }) + + await expect(listPlatformManagedUsers()).resolves.toEqual([managedUser]) + await expect(getPlatformUserOptions()).resolves.toEqual(userOptions) + await expect(createPlatformManagedUser(create)).resolves.toEqual(managedUser) + await expect(updatePlatformManagedUser(managedUser.membershipId, update)).resolves.toEqual(managedUser) + await expect(changePlatformManagedUserStatus(managedUser.membershipId, 'DISABLED')).resolves.toEqual(managedUser) + await expect(resetPlatformManagedUserPassword(managedUser.membershipId, 'ChangeMe123')).resolves.toEqual(managedUser) + + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/iam/users') + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/iam/users/options') + expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/users', create) + expect(http.put).toHaveBeenNthCalledWith(1, `/platform/v1/iam/users/${managedUser.membershipId}`, update) + expect(http.put).toHaveBeenNthCalledWith(2, `/platform/v1/iam/users/${managedUser.membershipId}/status`, { status: 'DISABLED' }) + expect(http.put).toHaveBeenNthCalledWith(3, `/platform/v1/iam/users/${managedUser.membershipId}/password`, { newPassword: 'ChangeMe123' }) + }) + it('loads the current users permission snapshot without calling the role-management endpoint', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: ['platform:warehouse:list'] }) @@ -197,7 +262,7 @@ describe('platform IAM roles', () => { it('loads paginated tenants with the documented filters', async () => { const page = { records: [], page: 2, size: 10, total: 11, pages: 2 } - vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: page }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { ...page, page: '2', size: '10', total: '11', pages: '2' } }) await expect(listPlatformTenants({ page: 2, size: 10, keyword: '柏悦', status: 'ACTIVE' })).resolves.toEqual(page) expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/tenants', { @@ -205,6 +270,55 @@ describe('platform IAM roles', () => { }) }) + it('creates, updates, and changes customer status with the platform tenant contract', async () => { + const tenant = { id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'ACTIVE' } + const create = { + tenantName: '柏悦物流', legalName: '柏悦物流有限公司', unifiedSocialCreditCode: '91440300MA5ABCDE12', + contactName: '张三', contactPhone: '13800138000', contactEmail: 'ops@example.com', servicePackage: '标准版', + contactWechat: 'baiyue-ops', salesOwnerName: '李四', paymentTermDays: 30, enterpriseType: '有限责任公司', + establishedDate: '2020-01-01', registeredCapital: 1000, registeredCapitalCurrency: 'CNY', + registeredAddress: '深圳市宝安区', internalRemark: '重点客户', + } + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: tenant }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: tenant }) + + await expect(createPlatformTenant(create)).resolves.toEqual(tenant) + const { unifiedSocialCreditCode: _creditCode, ...update } = create + await expect(updatePlatformTenant('8', update)).resolves.toEqual(tenant) + await expect(changePlatformTenantStatus('8', 'DISABLED')).resolves.toEqual(tenant) + + expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/tenants', create) + expect(http.put).toHaveBeenNthCalledWith(1, '/platform/v1/iam/tenants/8', update) + expect(http.put).toHaveBeenNthCalledWith(2, '/platform/v1/iam/tenants/8/status', { status: 'DISABLED' }) + }) + + it('loads real order totals for the visible customer tenants', async () => { + const counts = [{ tenantId: '8', orderCount: 12 }, { tenantId: '9', orderCount: 0 }] + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: counts }) + await expect(listPlatformTenantOrderCounts(['8', '9'])).resolves.toEqual(counts) + expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg/tenant-counts?tenantIds=8,9') + }) + + it('uploads qualification metadata before explicitly submitting the customer review', async () => { + const uploadResult = { qualificationId: '91', reviewId: null, tenantStatus: 'PROFILE_REQUIRED', documentUrl: '/file.pdf' } + const tenant = { id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'PENDING' } + vi.mocked(http.post).mockResolvedValueOnce({ code: '200', message: 'success', data: uploadResult }) + .mockResolvedValueOnce({ code: '200', message: 'success', data: tenant }) + const file = new File(['%PDF-1.4'], '营业执照.pdf', { type: 'application/pdf' }) + + await expect(uploadPlatformTenantQualification('8', 'BUSINESS_LICENSE', file, '2030-12-31', '91440300MA5ABCDE12', true)).resolves.toEqual(uploadResult) + await expect(submitPlatformTenantReview('8')).resolves.toEqual(tenant) + + const form = vi.mocked(http.post).mock.calls[0]?.[1] as FormData + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/files/qualifications/8', expect.any(FormData)) + expect(form.get('file')).toBe(file) + expect(form.get('qualificationType')).toBe('BUSINESS_LICENSE') + expect(form.get('validTo')).toBe('2030-12-31') + expect(form.get('certificateNo')).toBe('91440300MA5ABCDE12') + expect(form.get('replace')).toBe('true') + expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/iam/tenants/8/submit-review', {}) + }) + it('downloads a qualification with the platform file endpoint and a safe filename', async () => { const blob = new Blob(['image'], { type: 'image/png' }) vi.mocked(http.get).mockResolvedValue(blob) diff --git a/src/api/platformIam.ts b/src/api/platformIam.ts index 79f54fa4..14baaf9a 100644 --- a/src/api/platformIam.ts +++ b/src/api/platformIam.ts @@ -46,6 +46,44 @@ export interface PlatformTenantResource { contactName: string | null contactPhone: string | null contactEmail: string | null + servicePackage: string | null + contactWechat: string | null + salesOwnerName: string | null + paymentTermDays: number | null + enterpriseType: string | null + establishedDate: string | null + registeredCapital: number | string | null + registeredCapitalCurrency: string | null + registeredAddress: string | null + internalRemark: string | null + createdAt: string | null + approvedAt: string | null +} + +export interface CreatePlatformTenantRequest { + tenantName: string + legalName: string + unifiedSocialCreditCode: string + contactName: string + contactPhone: string + contactEmail: string + servicePackage: string + contactWechat: string + salesOwnerName: string + paymentTermDays: number + enterpriseType: string + establishedDate: string | null + registeredCapital: number | null + registeredCapitalCurrency: string + registeredAddress: string + internalRemark: string +} + +export type UpdatePlatformTenantRequest = Omit + +export interface PlatformTenantOrderCount { + tenantId: number | string + orderCount: number } export interface PlatformTenantPage { @@ -71,6 +109,17 @@ export interface PlatformTenantQualification { documentUrl: string status: 'PENDING' | 'APPROVED' | 'REJECTED' reviewComment: string | null + validTo?: string | null +} + +export type PlatformQualificationType = 'BUSINESS_LICENSE' | 'LEGAL_PERSON_ID' | 'FREIGHT_FORWARDER_CERTIFICATE' | 'BANK_ACCOUNT_LICENSE' + +export interface PlatformQualificationUploadResponse { + qualificationId: number | string + reviewId: number | string | null + tenantStatus: PlatformTenantStatus + documentUrl: string + replacedDocumentUrl?: string | null } export interface PlatformRoleResource { @@ -146,6 +195,49 @@ export interface UpdatePlatformInternalUserRequest { roleIds: Array } +export interface PlatformManagedUser { + membershipId: number | string + userId: number | string + authUserId: number | string + username: string + displayName: string + phone: string | null + email: string | null + tenantId: number | string + tenantName: string + memberType: string + memberNo: string | null + status: 'ACTIVE' | 'DISABLED' + roleIds: Array + roleNames: string[] + lastLoginAt: string | null + protectedAccount: boolean +} + +export interface PlatformUserOptions { + tenants: Array<{ id: number | string; name: string; status: string }> + roles: Array<{ id: number | string; tenantId: number | string; name: string; status: string }> +} + +export interface CreatePlatformManagedUserRequest { + username: string + displayName: string + phone: string + email: string + tenantId: number | string + roleIds: Array + password: string + status: 'ACTIVE' | 'DISABLED' +} + +export interface UpdatePlatformManagedUserRequest { + displayName: string + phone: string + email: string + tenantId: number | string + roleIds: Array +} + async function getData(path: string): Promise { const response = await http.get, ApiEnvelope>(path) return unwrapApiResponse(response) @@ -190,9 +282,62 @@ export async function listPlatformTenants(query: PlatformTenantQuery) { }, }, ) + const data = unwrapApiResponse(response) + return { + ...data, + page: Number(data.page), + size: Number(data.size), + total: Number(data.total), + pages: Number(data.pages), + } +} + +export function getPlatformTenant(tenantId: number | string) { + return getData(`/platform/v1/iam/tenants/${tenantId}`) +} + +export function createPlatformTenant(payload: CreatePlatformTenantRequest) { + return postData('/platform/v1/iam/tenants', payload) +} + +export function updatePlatformTenant(tenantId: number | string, payload: UpdatePlatformTenantRequest) { + return putData(`/platform/v1/iam/tenants/${tenantId}`, payload) +} + +export function changePlatformTenantStatus(tenantId: number | string, status: 'ACTIVE' | 'DISABLED') { + return putData(`/platform/v1/iam/tenants/${tenantId}/status`, { status }) +} + +export function submitPlatformTenantReview(tenantId: number | string) { + return postData(`/platform/v1/iam/tenants/${tenantId}/submit-review`, {}) +} + +export async function uploadPlatformTenantQualification( + tenantId: number | string, + qualificationType: PlatformQualificationType, + file: File, + validTo?: string, + certificateNo?: string, + replace = false, +) { + const form = new FormData() + form.append('file', file) + form.append('qualificationType', qualificationType) + if (validTo) form.append('validTo', validTo) + if (certificateNo) form.append('certificateNo', certificateNo) + if (replace) form.append('replace', 'true') + const response = await http.post, ApiEnvelope>( + `/platform/v1/files/qualifications/${tenantId}`, + form, + ) return unwrapApiResponse(response) } +export function listPlatformTenantOrderCounts(tenantIds: Array) { + const ids = tenantIds.map((id) => encodeURIComponent(String(id))).join(',') + return getData(`/platform/v1/orders/first-leg/tenant-counts?tenantIds=${ids}`) +} + export function listTenantQualifications(tenantId: number | string) { return getData(`/platform/v1/iam/tenants/${tenantId}/qualifications`) } @@ -284,6 +429,30 @@ export function assignPlatformInternalUserRoles(membershipId: number | string, r return putData(`/platform/v1/iam/internal-users/${membershipId}/roles`, { roleIds }) } +export function listPlatformManagedUsers() { + return getData('/platform/v1/iam/users') +} + +export function getPlatformUserOptions() { + return getData('/platform/v1/iam/users/options') +} + +export function createPlatformManagedUser(payload: CreatePlatformManagedUserRequest) { + return postData('/platform/v1/iam/users', payload) +} + +export function updatePlatformManagedUser(membershipId: number | string, payload: UpdatePlatformManagedUserRequest) { + return putData(`/platform/v1/iam/users/${membershipId}`, payload) +} + +export function changePlatformManagedUserStatus(membershipId: number | string, status: 'ACTIVE' | 'DISABLED') { + return putData(`/platform/v1/iam/users/${membershipId}/status`, { status }) +} + +export function resetPlatformManagedUserPassword(membershipId: number | string, newPassword: string) { + return putData(`/platform/v1/iam/users/${membershipId}/password`, { newPassword }) +} + export function approveEnterpriseReview(reviewId: number, comment: string) { return postData(`/platform/v1/iam/reviews/${reviewId}/approve`, { comment }) } diff --git a/src/api/platformPartners.test.ts b/src/api/platformPartners.test.ts index 70b57950..f599a1f7 100644 --- a/src/api/platformPartners.test.ts +++ b/src/api/platformPartners.test.ts @@ -3,6 +3,9 @@ import { http } from '@/api/http' import { changePlatformPartnerStatus, createPlatformPartner, + downloadPlatformPartnerImportTemplate, + getPlatformPartner, + importPlatformPartners, listPlatformPartners, updatePlatformPartner, type SavePlatformPartnerRequest, @@ -17,6 +20,7 @@ const payload: SavePlatformPartnerRequest = { partnerCode: 'CUS-001', partnerName: '测试往来单位', partnerType: 'CUSTOMER', + types: ['CUSTOMER', 'SHIPPER'], contactName: '测试联系人', contactPhone: '13800000000', contactEmail: null, @@ -27,6 +31,7 @@ const payload: SavePlatformPartnerRequest = { addressLine: '测试地址 1 号', postalCode: null, status: 'ACTIVE', + remark: '测试档案', } describe('platform partner API', () => { @@ -56,4 +61,31 @@ describe('platform partner API', () => { expect(http.put).toHaveBeenCalledWith('/platform/v1/partners/19001', payload, { params: { tenantId: 10001 } }) expect(http.patch).toHaveBeenCalledWith('/platform/v1/partners/19001/status', { status: 'INACTIVE' }, { params: { tenantId: 10001 } }) }) + + it('normalizes legacy detail types and uses import contracts', async () => { + vi.mocked(http.get) + .mockResolvedValueOnce({ code: '200', message: 'success', data: { + id: '19001', tenantId: 10001, partnerCode: 'CUS-001', partnerName: '测试单位', + partnerType: 'BOTH', contactName: '张三', contactPhone: '13800000000', contactEmail: null, + countryCode: 'CN', province: null, city: '深圳市', district: null, addressLine: '测试地址', + postalCode: null, status: 'ACTIVE', + } }) + .mockResolvedValueOnce(new Blob(['template'])) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { + fileName: 'partners.xlsx', totalRows: 1, importedRows: 1, success: true, errors: [], + } }) + + const detail = await getPlatformPartner(10001, '19001') + const template = await downloadPlatformPartnerImportTemplate() + const file = new File(['xlsx'], 'partners.xlsx') + const result = await importPlatformPartners(10001, file) + + expect(detail.types).toEqual(['CUSTOMER', 'SUPPLIER']) + expect(detail.dataScopeId).toBe(10001) + expect(template).toBeInstanceOf(Blob) + expect(result.success).toBe(true) + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/partners/19001', { params: { tenantId: 10001 } }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/partners/import-template', { responseType: 'blob' }) + expect(http.post).toHaveBeenCalledWith('/platform/v1/partners/import', expect.any(FormData), { params: { tenantId: 10001 } }) + }) }) diff --git a/src/api/platformPartners.ts b/src/api/platformPartners.ts index 732ebe31..f1ab3100 100644 --- a/src/api/platformPartners.ts +++ b/src/api/platformPartners.ts @@ -1,14 +1,17 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/api/http' -export type PlatformPartnerType = 'CUSTOMER' | 'SUPPLIER' | 'BOTH' +export type PlatformPartnerType = 'CUSTOMER' | 'SUPPLIER' | 'CARRIER' | 'CUSTOMS_BROKER' | 'SHIPPER' | 'CONSIGNEE' +export type PlatformPartnerLegacyType = PlatformPartnerType | 'BOTH' export type PlatformPartnerStatus = 'ACTIVE' | 'INACTIVE' export interface PlatformPartnerResource { id: number | string tenantId: number | string + dataScopeId: number | string partnerCode: string partnerName: string - partnerType: PlatformPartnerType + partnerType: PlatformPartnerLegacyType + types: PlatformPartnerType[] contactName: string contactPhone: string contactEmail: string | null @@ -19,6 +22,7 @@ export interface PlatformPartnerResource { addressLine: string postalCode: string | null status: PlatformPartnerStatus + remark: string | null createdAt?: string | null updatedAt?: string | null } @@ -50,6 +54,7 @@ export interface SavePlatformPartnerRequest { partnerCode: string partnerName: string partnerType: PlatformPartnerType + types: PlatformPartnerType[] contactName: string contactPhone: string contactEmail: string | null @@ -60,10 +65,37 @@ export interface SavePlatformPartnerRequest { addressLine: string postalCode: string | null status: PlatformPartnerStatus + remark: string | null +} + +export interface PlatformPartnerImportError { + row: number + field: string + message: string +} + +export interface PlatformPartnerImportResult { + fileName: string + totalRows: number + importedRows: number + success: boolean + errors: PlatformPartnerImportError[] } const basePath = '/platform/v1/partners' +function normalizePartner(resource: PlatformPartnerResource): PlatformPartnerResource { + const legacyTypes: PlatformPartnerType[] = resource.partnerType === 'BOTH' + ? ['CUSTOMER', 'SUPPLIER'] + : [resource.partnerType] + return { + ...resource, + dataScopeId: resource.dataScopeId ?? resource.tenantId, + types: resource.types?.length ? resource.types : legacyTypes, + remark: resource.remark ?? null, + } +} + export async function listPlatformPartners(tenantId: number, query: PlatformPartnerQuery) { const response = await http.get, ApiEnvelope>(basePath, { params: { tenantId, ...query }, @@ -71,6 +103,7 @@ export async function listPlatformPartners(tenantId: number, query: PlatformPart const page = unwrapApiResponse(response) return { ...page, + records: page.records.map(normalizePartner), page: Number(page.page), size: Number(page.size), total: Number(page.total), @@ -78,6 +111,29 @@ export async function listPlatformPartners(tenantId: number, query: PlatformPart } } +export async function getPlatformPartner(tenantId: number, partnerId: number | string) { + const response = await http.get, ApiEnvelope>( + `${basePath}/${partnerId}`, + { params: { tenantId } }, + ) + return normalizePartner(unwrapApiResponse(response)) +} + +export async function downloadPlatformPartnerImportTemplate() { + return http.get(`${basePath}/import-template`, { responseType: 'blob' }) +} + +export async function importPlatformPartners(tenantId: number, file: File) { + const form = new FormData() + form.append('file', file) + const response = await http.post, ApiEnvelope>( + `${basePath}/import`, + form, + { params: { tenantId } }, + ) + return unwrapApiResponse(response) +} + export async function createPlatformPartner(tenantId: number, payload: SavePlatformPartnerRequest) { const response = await http.post, ApiEnvelope>(basePath, payload, { params: { tenantId }, diff --git a/src/router/index.ts b/src/router/index.ts index 0fecbe2d..ebdb86ca 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -66,7 +66,7 @@ const router = createRouter({ path: 'system/users', name: 'users', component: () => import('@/views/InternalUsersView.vue'), - meta: { title: '内部用户', description: '管理平台内部员工账号、状态和角色', permission: 'platform:user:list' }, + meta: { title: '用户管理', description: '统一管理平台和企业租户用户、状态及角色', permission: 'platform:user:list' }, }, { path: 'system/roles', diff --git a/src/views/CustomersView.test.ts b/src/views/CustomersView.test.ts new file mode 100644 index 00000000..0d2cbbe4 --- /dev/null +++ b/src/views/CustomersView.test.ts @@ -0,0 +1,83 @@ +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getPlatformTenant, + listPlatformTenants, + listTenantQualifications, + type PlatformTenantResource, +} from '@/api/platformIam' +import { useAccessPreviewStore } from '@/stores/accessPreview' +import { useAuthStore } from '@/stores/auth' +import CustomersView from './CustomersView.vue' + +vi.mock('@/api/platformIam', () => ({ + changePlatformTenantStatus: vi.fn(), + createPlatformTenant: vi.fn(), + getPlatformTenant: vi.fn(), + listPlatformTenantOrderCounts: vi.fn(), + listPlatformTenants: vi.fn(), + listTenantQualifications: vi.fn(), + submitPlatformTenantReview: vi.fn(), + updatePlatformTenant: vi.fn(), + uploadPlatformTenantQualification: vi.fn(), +})) + +class ResizeObserverStub implements ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +const fullTenant: PlatformTenantResource = { + id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'PROFILE_REQUIRED', + legalName: '柏悦物流有限公司', unifiedSocialCreditCode: '91440300MA5ABCDE12', + contactName: '张三', contactPhone: '13800138000', contactEmail: 'ops@example.com', + servicePackage: '基础版', contactWechat: 'baiyue-ops', salesOwnerName: '李四', paymentTermDays: 30, + enterpriseType: '有限责任公司', establishedDate: '2020-01-01', registeredCapital: 1000, + registeredCapitalCurrency: 'CNY', registeredAddress: '深圳市宝安区', internalRemark: '重点客户', + createdAt: '2026-08-01T10:00:00', approvedAt: null, +} + +function tableButtonByText(text: string) { + return [...document.querySelectorAll('.el-table__body button')] + .find((button) => button.textContent?.trim() === text) +} + +enableAutoUnmount(afterEach) + +describe('CustomersView', () => { + let pinia: ReturnType + + beforeEach(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + document.body.innerHTML = '' + pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + auth.token = 'token' + auth.permissions = ['platform:customer:list', 'platform:customer:view', 'platform:customer:edit'] + useAccessPreviewStore().initializeAfterLogin() + vi.mocked(listPlatformTenants).mockResolvedValue({ + records: [{ ...fullTenant, contactPhone: '138****8000', contactEmail: 'o***@example.com' }], + page: 1, size: 10, total: 1, pages: 1, + }) + vi.mocked(getPlatformTenant).mockResolvedValue(fullTenant) + vi.mocked(listTenantQualifications).mockResolvedValue([]) + }) + + it('loads the complete customer detail before editing masked list fields', async () => { + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + tableButtonByText('编辑')?.click() + await flushPromises() + + expect(getPlatformTenant).toHaveBeenCalledWith(fullTenant.id) + 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) + }) +}) diff --git a/src/views/CustomersView.vue b/src/views/CustomersView.vue index 22197efa..4fa30376 100644 --- a/src/views/CustomersView.vue +++ b/src/views/CustomersView.vue @@ -1,165 +1,391 @@ diff --git a/src/views/EnterpriseReviewsView.test.ts b/src/views/EnterpriseReviewsView.test.ts index 5bc7a011..ec9077eb 100644 --- a/src/views/EnterpriseReviewsView.test.ts +++ b/src/views/EnterpriseReviewsView.test.ts @@ -7,6 +7,7 @@ import { downloadTenantQualification, listEnterpriseReviews, listTenantQualifications, + qualificationFileName, rejectEnterpriseReview, type EnterpriseReview, type PlatformTenantQualification, @@ -76,6 +77,11 @@ function buttonByText(text: string) { .find((button) => button.textContent?.trim() === text) } +function tableButtonByText(text: string) { + return [...document.querySelectorAll('.el-table__body button')] + .find((button) => button.textContent?.trim() === text) +} + enableAutoUnmount(afterEach) describe('EnterpriseReviewsView', () => { @@ -86,6 +92,10 @@ describe('EnterpriseReviewsView', () => { vi.mocked(downloadTenantQualification).mockReset() vi.mocked(approveEnterpriseReview).mockReset() vi.mocked(rejectEnterpriseReview).mockReset() + vi.mocked(qualificationFileName).mockReset() + vi.mocked(qualificationFileName).mockReturnValue('test.png') + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:qualification-preview') }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) document.body.innerHTML = '' }) @@ -96,7 +106,7 @@ describe('EnterpriseReviewsView', () => { expect(listEnterpriseReviews).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain('暂无待审核企业') - expect(wrapper.text()).toContain('共 0 条待审记录') + expect(wrapper.text()).toContain('共 0 条') }) it('shows review identity and submits approval with the review id', async () => { @@ -107,7 +117,7 @@ describe('EnterpriseReviewsView', () => { expect(wrapper.text()).toContain('深圳测试跨境科技有限公司') expect(wrapper.text()).toContain('91440300XXXXXXXXXX') - const approve = buttonByText('通过') + const approve = tableButtonByText('通过') expect(approve).toBeDefined() approve?.click() await flushPromises() @@ -119,10 +129,10 @@ describe('EnterpriseReviewsView', () => { expect(approveEnterpriseReview).toHaveBeenCalledWith( pendingReview.id, - '营业执照清晰,企业资料审核通过', + '企业资料与必填资质完整、清晰,审核通过', ) expect(wrapper.text()).toContain('暂无待审核企业') - expect(wrapper.text()).toContain('共 0 条待审记录') + expect(wrapper.text()).toContain('共 0 条') }) it('loads tenant qualifications when the reviewer opens the detail drawer', async () => { @@ -130,7 +140,7 @@ describe('EnterpriseReviewsView', () => { vi.mocked(listTenantQualifications).mockResolvedValue([pendingQualification]) const wrapper = await mountView() - buttonByText('详情')?.click() + tableButtonByText('详情')?.click() await flushPromises() expect(listTenantQualifications).toHaveBeenCalledWith(pendingReview.tenantId) @@ -143,7 +153,7 @@ describe('EnterpriseReviewsView', () => { vi.mocked(listTenantQualifications).mockRejectedValue(new Error('qualification unavailable')) const wrapper = await mountView() - buttonByText('详情')?.click() + tableButtonByText('详情')?.click() await flushPromises() expect(wrapper.text()).toContain('资质暂时无法加载') @@ -152,6 +162,23 @@ describe('EnterpriseReviewsView', () => { expect(buttonByText('重新加载')).toBeDefined() }) + it('renders uploaded PDF qualifications in a PDF preview frame', async () => { + vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview]) + vi.mocked(listTenantQualifications).mockResolvedValue([pendingQualification]) + vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['%PDF-1.4'], { type: 'application/pdf' })) + vi.mocked(qualificationFileName).mockReturnValue('test.pdf') + + await mountView() + tableButtonByText('详情')?.click() + await flushPromises() + buttonByText('查看文件')?.click() + await flushPromises() + + const frame = document.querySelector('.qualification-preview iframe') + expect(frame?.getAttribute('src')).toBe('blob:qualification-preview') + expect(frame?.getAttribute('title')).toContain('PDF 预览') + }) + it('keeps the page recoverable when the endpoint fails', async () => { vi.mocked(listEnterpriseReviews).mockRejectedValue(new Error('IAM unavailable')) diff --git a/src/views/EnterpriseReviewsView.vue b/src/views/EnterpriseReviewsView.vue index 2ea8bfa8..e42f9748 100644 --- a/src/views/EnterpriseReviewsView.vue +++ b/src/views/EnterpriseReviewsView.vue @@ -39,6 +39,7 @@ const previewLoadingId = ref() const previewOpen = ref(false) const previewUrl = ref('') const previewName = ref('') +const previewKind = ref<'image' | 'pdf' | ''>('') const decisionOpen = ref(false) const submitting = ref(false) const decision = reactive({ type: 'approve' as 'approve' | 'reject', comment: '' }) @@ -93,7 +94,13 @@ function formatDate(value?: string | null) { } function qualificationName(value?: string | null) { - return value === 'BUSINESS_LICENSE' ? '营业执照' : displayText(value) + const names: Record = { + BUSINESS_LICENSE: '营业执照', + LEGAL_PERSON_ID: '法人身份证', + FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书', + BANK_ACCOUNT_LICENSE: '银行开户许可证', + } + return value ? (names[value] || value) : '—' } function qualificationStatus(status: PlatformTenantQualification['status']) { @@ -148,6 +155,7 @@ function clearPreviewUrl() { if (previewUrl.value) URL.revokeObjectURL(previewUrl.value) previewUrl.value = '' previewName.value = '' + previewKind.value = '' } async function previewQualification(qualification: PlatformTenantQualification) { @@ -160,6 +168,7 @@ async function previewQualification(qualification: PlatformTenantQualification) clearPreviewUrl() previewUrl.value = URL.createObjectURL(blob) previewName.value = qualificationFileName(qualification.documentUrl) + previewKind.value = blob.type === 'application/pdf' || previewName.value.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image' previewOpen.value = true } catch (error) { ElMessage.error(getApiErrorMessage(error, '资质文件加载失败,请稍后重试')) @@ -171,7 +180,7 @@ async function previewQualification(qualification: PlatformTenantQualification) function openDecision(review: EnterpriseReview, type: 'approve' | 'reject') { selectedReview.value = review decision.type = type - decision.comment = type === 'approve' ? '营业执照清晰,企业资料审核通过' : '' + decision.comment = type === 'approve' ? '企业资料与必填资质完整、清晰,审核通过' : '' decisionOpen.value = true void nextTick(() => decisionFormRef.value?.clearValidate('comment')) } @@ -315,7 +324,8 @@ onBeforeUnmount(clearPreviewUrl)
- + +