feat(M08-D): 补加盟申请与跟进运营
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
|
||||
const secret = 'test-only-franchise-management-secret-32';
|
||||
const token = signAccessToken({ sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1 }, secret, 900);
|
||||
let submitted;
|
||||
let listInput;
|
||||
let assignment;
|
||||
let followUp;
|
||||
const repository = {
|
||||
async submitApplication(input) { submitted = input; return { applicationId: '101', applicationNo: 'FR101', idempotent: false }; },
|
||||
async listApplications(input) { listInput = input; return { items: [], total: 0, page: input.page, pageSize: input.pageSize }; },
|
||||
async getApplication(tenantId, id) { return { application: { id, tenantId }, followUps: [] }; },
|
||||
async assignApplication(_actor, tenantId, id, assigneeUserId) { assignment = { tenantId, id, assigneeUserId }; return { applicationId: id, assigneeUserId }; },
|
||||
async addFollowUp(_actor, tenantId, id, input) { followUp = { tenantId, id, input }; return { applicationId: id, followUpId: '301', status: input.status || 'NEW' }; }
|
||||
};
|
||||
const app = await buildApp({ franchise: {
|
||||
repository,
|
||||
platformConfig: { async resolveBootstrap(appId, tenantId) { return appId === 'wx-franchise' ? { tenantId: tenantId || '7' } : null; } },
|
||||
authRepository: { async validateSession() { return { id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000), user: { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1, nickname: '', avatarUrl: '', phone: '' } }; } },
|
||||
accessControl: { async getAccessProfile() { return { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] }; } },
|
||||
jwtSecret: secret
|
||||
} });
|
||||
|
||||
const created = await app.inject({ method: 'POST', url: '/app-api/franchise-applications', headers: { 'x-wechat-appid': 'wx-franchise' }, payload: { city: '上海市', contactName: '张先生', contactPhone: '13800138000', message: '计划开店', clientRequestId: 'franchise-request-001' } });
|
||||
assert.equal(created.statusCode, 201);
|
||||
assert.equal(submitted.tenantId, '7');
|
||||
assert.equal(submitted.contactPhone, '13800138000');
|
||||
assert.equal('contactPhone' in (submitted.audit || {}), false);
|
||||
|
||||
const auth = { authorization: `Bearer ${token}` };
|
||||
const listed = await app.inject({ method: 'GET', url: '/admin-api/franchise-applications?status=NEW&page=2&pageSize=10', headers: auth });
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.deepEqual({ tenantId: listInput.tenantId, status: listInput.status, page: listInput.page }, { tenantId: '7', status: 'NEW', page: 2 });
|
||||
const crossTenant = await app.inject({ method: 'GET', url: '/admin-api/franchise-applications?tenantId=8', headers: auth });
|
||||
assert.equal(crossTenant.statusCode, 403);
|
||||
const assigned = await app.inject({ method: 'PATCH', url: '/admin-api/franchise-applications/101/assignee', headers: auth, payload: { assigneeUserId: '22' } });
|
||||
assert.equal(assigned.statusCode, 200);
|
||||
assert.deepEqual(assignment, { tenantId: '7', id: '101', assigneeUserId: '22' });
|
||||
const followed = await app.inject({ method: 'POST', url: '/admin-api/franchise-applications/101/follow-ups', headers: auth, payload: { followUpType: 'CALL', note: '已电话沟通', status: 'CONTACTED' } });
|
||||
assert.equal(followed.statusCode, 201);
|
||||
assert.equal(followUp.input.status, 'CONTACTED');
|
||||
await app.close();
|
||||
console.log('PASS: M08-D franchise submission, tenant scope, assignment and follow-up routes are present.');
|
||||
@@ -102,6 +102,9 @@ const staffManagementVerifySql = read('database/migrations/2026081001_m08c_staff
|
||||
const contentAssetScopeUpSql = read('database/migrations/2026081002_m08d_content_asset_scope.up.sql');
|
||||
const contentAssetScopeDownSql = read('database/migrations/2026081002_m08d_content_asset_scope.down.sql');
|
||||
const contentAssetScopeVerifySql = read('database/migrations/2026081002_m08d_content_asset_scope.verify.sql');
|
||||
const franchiseUpSql = read('database/migrations/2026081003_m08d_franchise_leads.up.sql');
|
||||
const franchiseDownSql = read('database/migrations/2026081003_m08d_franchise_leads.down.sql');
|
||||
const franchiseVerifySql = read('database/migrations/2026081003_m08d_franchise_leads.verify.sql');
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -473,4 +476,11 @@ assert.match(contentAssetScopeDownSql, /SET a\.image_asset_id = duplicate_group\
|
||||
assert.match(contentAssetScopeDownSql, /uq_qipai_media_tenant_checksum/);
|
||||
assert.match(contentAssetScopeVerifySql, /generation_expression/);
|
||||
|
||||
assert.match(franchiseUpSql, /CREATE TABLE IF NOT EXISTS qipai_franchise_applications/);
|
||||
assert.match(franchiseUpSql, /CREATE TABLE IF NOT EXISTS qipai_franchise_follow_ups/);
|
||||
assert.match(franchiseUpSql, /uq_qipai_franchise_client_request/);
|
||||
assert.match(franchiseUpSql, /'2026081003'/);
|
||||
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
|
||||
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
|
||||
|
||||
console.log('PASS: M01-B through M08-D migration contracts are present.');
|
||||
|
||||
@@ -41,14 +41,15 @@ assert.match(plan.file, /2026062627_m08b_cleaning_collaboration\.up\.sql/);
|
||||
assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql/);
|
||||
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
|
||||
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
|
||||
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql$/);
|
||||
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
|
||||
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
const verifyPlan = await loadMigrationPlan('verify');
|
||||
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
|
||||
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
|
||||
assert.match(verifyPlan.file, /2026081002_m08d_content_asset_scope\.verify\.sql$/);
|
||||
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql$/);
|
||||
|
||||
const calls = [];
|
||||
const fakePool = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { RbacRepository } from '../dist/auth/rbac-repository.js';
|
||||
import { UserManagementRepository } from '../dist/auth/user-management-repository.js';
|
||||
import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-repository.js';
|
||||
import { ContentRepository, ContentError } from '../dist/content/content-repository.js';
|
||||
import { FranchiseRepository, FranchiseError } from '../dist/franchise/franchise-repository.js';
|
||||
import { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js';
|
||||
import { StoreAccessRepository, StoreAccessError } from '../dist/stores/access-repository.js';
|
||||
import { PricingRepository, PricingError } from '../dist/orders/pricing-repository.js';
|
||||
@@ -60,6 +61,8 @@ const expectedTables = [
|
||||
'qipai_device_status_snapshots',
|
||||
'qipai_devices',
|
||||
'qipai_direct_bookings',
|
||||
'qipai_franchise_applications',
|
||||
'qipai_franchise_follow_ups',
|
||||
'qipai_group_redemptions',
|
||||
'qipai_group_vouchers',
|
||||
'qipai_holiday_calendar',
|
||||
@@ -129,13 +132,13 @@ async function readMigrationVersions(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT version, name
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ORDER BY version`,
|
||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
||||
'2026062220', '2026081002']
|
||||
'2026062220', '2026081002', '2026081003']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -1748,6 +1751,50 @@ async function assertContentManagement(pool, context) {
|
||||
);
|
||||
}
|
||||
|
||||
async function assertFranchiseManagement(pool, context) {
|
||||
const [adminRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
|
||||
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
|
||||
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`, [context.tenantId]
|
||||
);
|
||||
const adminId = String(adminRows[0].id);
|
||||
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
|
||||
const actor = { tenantId: context.tenantId, userId: adminId, access,
|
||||
traceId: 'm08d-franchise-live', ip: '127.0.0.1', userAgent: 'M08-D franchise live test' };
|
||||
const repository = new FranchiseRepository(pool);
|
||||
const input = { tenantId: context.tenantId, city: '上海市', contactName: '测试联系人',
|
||||
contactPhone: '13800138000', message: '计划开设两家门店', source: 'MINIAPP',
|
||||
clientRequestId: 'm08d-franchise-idempotent', traceId: actor.traceId,
|
||||
ip: actor.ip, userAgent: actor.userAgent };
|
||||
const first = await repository.submitApplication(input);
|
||||
const duplicate = await repository.submitApplication(input);
|
||||
assert.equal(first.idempotent, false);
|
||||
assert.equal(duplicate.idempotent, true);
|
||||
assert.equal(duplicate.applicationId, first.applicationId);
|
||||
assert.equal((await repository.listApplications({ tenantId: context.tenantId,
|
||||
page: 1, pageSize: 20, status: 'NEW' })).total, 1);
|
||||
await assert.rejects(
|
||||
() => repository.assignApplication(actor, context.tenantId, first.applicationId, '999999999'),
|
||||
(error) => error instanceof FranchiseError && error.code === 'FRANCHISE_ASSIGNEE_INVALID'
|
||||
);
|
||||
await repository.assignApplication(actor, context.tenantId, first.applicationId, adminId);
|
||||
await repository.addFollowUp(actor, context.tenantId, first.applicationId, {
|
||||
followUpType: 'CALL', note: '已完成首次电话沟通', status: 'CONTACTED', nextFollowUpAt: null
|
||||
});
|
||||
await assert.rejects(() => repository.addFollowUp(actor, context.tenantId, first.applicationId, {
|
||||
followUpType: 'NOTE', note: '跳过资格确认', status: 'CONVERTED'
|
||||
}), (error) => error instanceof FranchiseError && error.code === 'FRANCHISE_STATUS_TRANSITION_INVALID');
|
||||
const detail = await repository.getApplication(context.tenantId, first.applicationId);
|
||||
assert.equal(detail.application.status, 'CONTACTED');
|
||||
assert.equal(detail.followUps.length, 2);
|
||||
const [auditRows] = await pool.query(
|
||||
`SELECT CAST(metadata AS CHAR) AS metadata FROM qipai_audit_logs
|
||||
WHERE tenant_id = ? AND resource_type = 'FRANCHISE_APPLICATION'`, [context.tenantId]
|
||||
);
|
||||
assert.equal(auditRows.some((row) => row.metadata.includes('13800138000')), false);
|
||||
}
|
||||
|
||||
async function assertDeviceTopology(pool, context) {
|
||||
const [adminRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
@@ -2026,7 +2073,8 @@ try {
|
||||
{ version: '2026062218', name: 'm05d_profit_sharing' },
|
||||
{ version: '2026062219', name: 'm06b_device_topology' },
|
||||
{ version: '2026062220', name: 'm06c_iot_messages' },
|
||||
{ version: '2026081002', name: 'm08d_content_asset_scope' }
|
||||
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
||||
{ version: '2026081003', name: 'm08d_franchise_leads' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||
@@ -2034,6 +2082,7 @@ try {
|
||||
await assertUserManagement(pool, loginContext);
|
||||
await assertStoreRoomDomain(pool, loginContext);
|
||||
await assertContentManagement(pool, loginContext);
|
||||
await assertFranchiseManagement(pool, loginContext);
|
||||
await assertStoreDiscovery(pool, loginContext);
|
||||
await assertSceneAndWifiAccess(pool, loginContext);
|
||||
await assertPricingAndReservations(pool, loginContext);
|
||||
@@ -2077,7 +2126,8 @@ try {
|
||||
{ version: '2026062218', name: 'm05d_profit_sharing' },
|
||||
{ version: '2026062219', name: 'm06b_device_topology' },
|
||||
{ version: '2026062220', name: 'm06c_iot_messages' },
|
||||
{ version: '2026081002', name: 'm08d_content_asset_scope' }
|
||||
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
||||
{ version: '2026081003', name: 'm08d_franchise_leads' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
|
||||
Reference in New Issue
Block a user