feat(M08-D): 补加盟申请与跟进运营

This commit is contained in:
Codex
2026-08-10 13:17:19 +08:00
parent e0e17159a7
commit aa4a6bd014
28 changed files with 894 additions and 13 deletions
+45
View File
@@ -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.');
+10
View File
@@ -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.');
+3 -2
View File
@@ -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.');