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
+10 -1
View File
@@ -12,6 +12,7 @@
<nav class="nav-list" aria-label="后台模块">
<button class="nav-item" :class="{ active: activeModule === 'platformApps' }" type="button" @click="activeModule = 'platformApps'"><PanelsTopLeft :size="18" /><span>小程序租户</span></button>
<button class="nav-item" :class="{ active: activeModule === 'content' }" type="button" @click="activeModule = 'content'"><Images :size="18" /><span>广告装修</span></button>
<button class="nav-item" :class="{ active: activeModule === 'franchise' }" type="button" @click="activeModule = 'franchise'"><Handshake :size="18" /><span>加盟跟进</span></button>
<button
class="nav-item"
:class="{ active: activeModule === 'overview' }"
@@ -124,6 +125,11 @@
:session="session"
/>
<FranchisePanel
v-else-if="activeModule === 'franchise'"
:session="session"
/>
<StoresRoomsPanel
v-else-if="activeModule === 'stores'"
:session="session"
@@ -319,6 +325,7 @@ import {
LayoutDashboard,
ListTodo,
Images,
Handshake,
PanelsTopLeft,
RadioTower,
RotateCcw,
@@ -344,6 +351,7 @@ import MembersStaffPanel from './components/MembersStaffPanel.vue';
import DevicesPanel from './components/DevicesPanel.vue';
import PlatformAppsPanel from './components/PlatformAppsPanel.vue';
import ContentManagementPanel from './components/ContentManagementPanel.vue';
import FranchisePanel from './components/FranchisePanel.vue';
import {
ApiError,
assignCleaningTask,
@@ -380,7 +388,7 @@ import { money } from './format';
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
const tokenDraft = ref(savedToken.value);
const activeModule = ref<'overview' | 'platformApps' | 'content' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
const activeModule = ref<'overview' | 'platformApps' | 'content' | 'franchise' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
const activeTab = ref('tasks');
const lastError = ref('');
const lastMessage = ref('');
@@ -393,6 +401,7 @@ const activeModuleMeta = computed(() => ({
overview: { stage: 'M08-D', title: '平台运营总览' },
platformApps: { stage: 'M08-D', title: '多小程序与租户品牌配置' },
content: { stage: 'M08-D', title: '广告投放与门店装修' },
franchise: { stage: 'M08-D', title: '加盟申请与跟进运营' },
stores: { stage: 'M08-D', title: '门店与房间管理' },
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
devices: { stage: 'M08-D', title: '设备资产、拓扑与控制' },
+33
View File
@@ -9,6 +9,10 @@ import type {
CleaningTaskMember,
DecorationComponent,
DecorationVersion,
FranchiseApplication,
FranchiseApplicationDetail,
FranchiseFollowUpType,
FranchiseStatus,
DeviceTopology,
DeviceType,
BusinessStatistics,
@@ -499,6 +503,35 @@ export function updateAdvertisement(
);
}
export function listFranchiseApplications(session: ApiSession, input: {
page: number; pageSize: number; status?: FranchiseStatus; assigneeUserId?: string; search?: string;
}) {
const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
if (input.status) params.set('status', input.status);
if (input.assigneeUserId) params.set('assigneeUserId', input.assigneeUserId);
if (input.search) params.set('search', input.search);
return request<PageResult<FranchiseApplication>>(session, `/franchise-applications?${params}`);
}
export function getFranchiseApplication(session: ApiSession, applicationId: string) {
return request<FranchiseApplicationDetail>(session, `/franchise-applications/${encodeURIComponent(applicationId)}`);
}
export function assignFranchiseApplication(session: ApiSession, applicationId: string, assigneeUserId: string | null) {
return request<{ applicationId: string; assigneeUserId: string | null }>(session,
`/franchise-applications/${encodeURIComponent(applicationId)}/assignee`,
{ method: 'PATCH', body: JSON.stringify({ assigneeUserId }) });
}
export function addFranchiseFollowUp(session: ApiSession, applicationId: string, input: {
followUpType: Exclude<FranchiseFollowUpType, 'ASSIGNMENT' | 'STATUS'>;
note: string; nextFollowUpAt: string | null; status?: FranchiseStatus;
}) {
return request<{ applicationId: string; followUpId: string; status: FranchiseStatus }>(session,
`/franchise-applications/${encodeURIComponent(applicationId)}/follow-ups`,
{ method: 'POST', body: JSON.stringify(input) });
}
export function createStaffUser(
session: ApiSession,
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
+57
View File
@@ -0,0 +1,57 @@
<template>
<section class="franchise-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<section class="franchise-metrics"><span><small>新申请</small><strong>{{ count('NEW') }}</strong></span><span><small>已联系</small><strong>{{ count('CONTACTED') }}</strong></span><span><small>意向明确</small><strong>{{ count('QUALIFIED') }}</strong></span><span><small>本页待跟进</small><strong>{{ dueCount }}</strong></span></section>
<section class="panel">
<header class="panel-toolbar"><div><p class="section-kicker">M08-D · 加盟运营</p><h3>加盟申请与跟进队列</h3></div><el-button :icon="RefreshCw" :loading="loading" @click="loadData">刷新</el-button></header>
<div class="franchise-filters">
<el-input v-model="filters.search" clearable placeholder="申请号、城市、联系人或电话" @keyup.enter="applyFilters" />
<el-select v-model="filters.status" clearable placeholder="全部状态"><el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select>
<el-select v-model="filters.assigneeUserId" clearable filterable placeholder="全部负责人"><el-option v-for="user in staff" :key="user.id" :label="user.nickname || `员工 #${user.id}`" :value="user.id" /></el-select>
<el-button type="primary" @click="applyFilters">筛选</el-button>
</div>
<el-table v-loading="loading" :data="page.items" row-key="id" class="data-table">
<el-table-column label="申请" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ row.applicationNo }}</strong><span>{{ shortDate(row.createdAt) }} · {{ row.source }}</span></div></template></el-table-column>
<el-table-column label="联系人" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ row.contactName }} · {{ row.contactPhone }}</strong><span>{{ row.city }}</span></div></template></el-table-column>
<el-table-column label="需求" min-width="220" show-overflow-tooltip prop="message" />
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="负责人" min-width="130"><template #default="{ row }">{{ row.assigneeName || '待分派' }}</template></el-table-column>
<el-table-column label="下次跟进" min-width="150"><template #default="{ row }"><span :class="{ overdue: isOverdue(row.nextFollowUpAt) }">{{ row.nextFollowUpAt ? shortDate(row.nextFollowUpAt) : '-' }}</span></template></el-table-column>
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Eye" @click="openDetail(row.id)">详情</el-button></template></el-table-column>
</el-table>
<el-pagination v-model:current-page="page.page" :page-size="page.pageSize" :total="page.total" layout="prev, pager, next, total" @current-change="loadData" />
</section>
<el-drawer v-model="drawer" title="加盟申请详情" size="min(680px, 96vw)">
<div v-if="detail" class="franchise-detail">
<el-descriptions :column="2" border><el-descriptions-item label="申请号">{{ detail.application.applicationNo }}</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="statusTag(detail.application.status)">{{ statusLabel(detail.application.status) }}</el-tag></el-descriptions-item><el-descriptions-item label="联系人">{{ detail.application.contactName }}</el-descriptions-item><el-descriptions-item label="联系电话"><a :href="`tel:${detail.application.contactPhone}`">{{ detail.application.contactPhone }}</a></el-descriptions-item><el-descriptions-item label="意向城市">{{ detail.application.city }}</el-descriptions-item><el-descriptions-item label="提交时间">{{ shortDate(detail.application.createdAt) }}</el-descriptions-item><el-descriptions-item label="留言" :span="2">{{ detail.application.message || '无' }}</el-descriptions-item></el-descriptions>
<section class="franchise-assignment"><strong>负责人</strong><el-select :model-value="detail.application.assigneeUserId || ''" clearable filterable placeholder="待分派" @change="assign"><el-option v-for="user in staff" :key="user.id" :label="user.nickname || `员工 #${user.id}`" :value="user.id" /></el-select></section>
<section class="follow-up-form"><h4>记录跟进</h4><el-form label-position="top"><el-form-item label="方式"><el-select v-model="followForm.followUpType"><el-option label="电话" value="CALL" /><el-option label="微信" value="WECHAT" /><el-option label="面谈" value="MEETING" /><el-option label="备注" value="NOTE" /></el-select></el-form-item><el-form-item label="更新状态"><el-select v-model="followForm.status" clearable><el-option v-for="item in allowedStatusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="下次跟进"><el-date-picker v-model="followForm.nextFollowUpAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" clearable /></el-form-item><el-form-item label="跟进内容"><el-input v-model="followForm.note" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></el-form><el-button type="primary" :loading="saving" @click="saveFollowUp">保存跟进</el-button></section>
<section><h4>跟进历史</h4><el-timeline><el-timeline-item v-for="item in detail.followUps" :key="item.id" :timestamp="shortDate(item.createdAt)" placement="top"><div class="follow-up-card"><strong>{{ followTypeLabel(item.followUpType) }} · {{ item.actorName }}</strong><p>{{ item.note }}</p><small v-if="item.fromStatus !== item.toStatus">{{ item.fromStatus ? statusLabel(item.fromStatus) : '-' }} → {{ item.toStatus ? statusLabel(item.toStatus) : '-' }}</small></div></el-timeline-item></el-timeline><el-empty v-if="detail.followUps.length === 0" description="尚无跟进记录" :image-size="70" /></section>
</div>
</el-drawer>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Eye, RefreshCw } from '@lucide/vue';
import { ApiError, addFranchiseFollowUp, assignFranchiseApplication, getFranchiseApplication, listFranchiseApplications, listStaffUsers, type ApiSession } from '../api';
import { shortDate } from '../format';
import type { FranchiseApplication, FranchiseApplicationDetail, FranchiseFollowUpType, FranchiseStatus, ManagedUser, PageResult } from '../types';
const props = defineProps<{ session: ApiSession }>(); const loading = ref(false); const saving = ref(false); const lastError = ref(''); const drawer = ref(false); const staff = ref<ManagedUser[]>([]); const detail = ref<FranchiseApplicationDetail | null>(null);
const page = reactive<PageResult<FranchiseApplication>>({ items: [], total: 0, page: 1, pageSize: 20 }); const filters = reactive({ search: '', status: '' as FranchiseStatus | '', assigneeUserId: '' }); const followForm = reactive({ followUpType: 'CALL' as Exclude<FranchiseFollowUpType, 'ASSIGNMENT' | 'STATUS'>, status: '' as FranchiseStatus | '', nextFollowUpAt: '', note: '' });
const statusOptions: Array<{ value: FranchiseStatus; label: string }> = [{ value: 'NEW', label: '新申请' }, { value: 'CONTACTED', label: '已联系' }, { value: 'QUALIFIED', label: '意向明确' }, { value: 'REJECTED', label: '已拒绝' }, { value: 'CONVERTED', label: '已签约待开通' }];
const transitions: Record<FranchiseStatus, FranchiseStatus[]> = { NEW: ['CONTACTED', 'REJECTED'], CONTACTED: ['QUALIFIED', 'REJECTED'], QUALIFIED: ['CONTACTED', 'CONVERTED', 'REJECTED'], REJECTED: ['CONTACTED'], CONVERTED: [] };
const allowedStatusOptions = computed(() => detail.value ? statusOptions.filter((item) => transitions[detail.value!.application.status].includes(item.value)) : []); const dueCount = computed(() => page.items.filter((item) => isOverdue(item.nextFollowUpAt)).length);
function captureError(error: unknown) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; }
async function loadData() { if (!props.session.token) return; loading.value = true; lastError.value = ''; try { const [result, users] = await Promise.all([listFranchiseApplications(props.session, { page: page.page, pageSize: page.pageSize, status: filters.status || undefined, assigneeUserId: filters.assigneeUserId || undefined, search: filters.search.trim() || undefined }), staff.value.length ? Promise.resolve({ items: staff.value }) : listStaffUsers(props.session, { page: 1, pageSize: 100, status: 'ACTIVE' })]); Object.assign(page, result); staff.value = users.items; } catch (error) { captureError(error); } finally { loading.value = false; } }
function applyFilters() { page.page = 1; void loadData(); } async function openDetail(id: string) { drawer.value = true; detail.value = null; try { detail.value = await getFranchiseApplication(props.session, id); Object.assign(followForm, { followUpType: 'CALL', status: '', nextFollowUpAt: '', note: '' }); } catch (error) { captureError(error); } }
async function assign(value: string) { if (!detail.value) return; saving.value = true; try { await assignFranchiseApplication(props.session, detail.value.application.id, value || null); ElMessage.success('负责人已更新'); await refreshDetail(); await loadData(); } catch (error) { captureError(error); } finally { saving.value = false; } }
async function saveFollowUp() { if (!detail.value || !followForm.note.trim()) return ElMessage.warning('请填写跟进内容'); saving.value = true; try { await addFranchiseFollowUp(props.session, detail.value.application.id, { followUpType: followForm.followUpType, note: followForm.note.trim(), nextFollowUpAt: followForm.nextFollowUpAt || null, status: followForm.status || undefined }); ElMessage.success('跟进记录已保存'); await refreshDetail(); await loadData(); Object.assign(followForm, { status: '', note: '' }); } catch (error) { captureError(error); } finally { saving.value = false; } }
async function refreshDetail() { if (detail.value) detail.value = await getFranchiseApplication(props.session, detail.value.application.id); }
function count(status: FranchiseStatus) { return page.items.filter((item) => item.status === status).length; } function isOverdue(value: string | null) { return Boolean(value && new Date(value).getTime() < Date.now()); } function statusLabel(value: FranchiseStatus) { return statusOptions.find((item) => item.value === value)?.label || value; } function statusTag(value: FranchiseStatus) { return value === 'QUALIFIED' || value === 'CONVERTED' ? 'success' : value === 'REJECTED' ? 'info' : value === 'NEW' ? 'warning' : 'primary'; } function followTypeLabel(value: FranchiseFollowUpType) { return ({ CALL: '电话', WECHAT: '微信', MEETING: '面谈', NOTE: '备注', STATUS: '状态变化', ASSIGNMENT: '负责人变更' } as Record<FranchiseFollowUpType, string>)[value]; }
watch(() => props.session.token, (token) => { if (token) void loadData(); }, { immediate: true });
</script>
+105
View File
@@ -233,6 +233,111 @@
}
}
.franchise-page,
.franchise-detail {
display: grid;
gap: 16px;
}
.franchise-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.franchise-metrics > span {
display: grid;
gap: 6px;
padding: 16px 18px;
border: 1px solid #dbe3ec;
border-radius: 14px;
background: #fff;
box-shadow: 0 8px 24px rgba(31, 49, 72, .06);
}
.franchise-metrics small {
color: #66758a;
}
.franchise-metrics strong {
font-size: 26px;
}
.franchise-filters {
display: grid;
grid-template-columns: minmax(220px, 1fr) 150px 180px auto;
gap: 10px;
padding: 0 18px 16px;
}
.franchise-assignment {
display: grid;
grid-template-columns: 90px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 14px;
border: 1px solid #dbe3ec;
border-radius: 10px;
}
.follow-up-form {
padding: 16px;
border-radius: 12px;
background: #f5f7fa;
}
.follow-up-form h4,
.franchise-detail h4,
.follow-up-card p {
margin-top: 0;
}
.follow-up-form .el-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.follow-up-form .el-form-item:last-child {
grid-column: 1 / -1;
}
.follow-up-card {
padding: 12px;
border: 1px solid #dbe3ec;
border-radius: 9px;
background: #fff;
}
.follow-up-card p {
margin: 8px 0;
white-space: pre-wrap;
}
.follow-up-card small,
.overdue {
color: #c73535;
}
@media (max-width: 980px) {
.franchise-metrics,
.franchise-filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.franchise-metrics,
.franchise-filters,
.follow-up-form .el-form {
grid-template-columns: 1fr;
}
.follow-up-form .el-form-item:last-child {
grid-column: auto;
}
}
* {
box-sizing: border-box;
}
+21
View File
@@ -518,6 +518,27 @@ export interface Advertisement extends AdvertisementInput {
imageUrl: string;
}
export type FranchiseStatus = 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'REJECTED' | 'CONVERTED';
export type FranchiseFollowUpType = 'CALL' | 'WECHAT' | 'MEETING' | 'NOTE' | 'STATUS' | 'ASSIGNMENT';
export interface FranchiseApplication {
id: string; tenantId: string; applicationNo: string; city: string; contactName: string;
contactPhone: string; message: string; source: string; status: FranchiseStatus;
assigneeUserId: string | null; assigneeName: string | null; submittedUserId: string | null;
nextFollowUpAt: string | null; closedAt: string | null; createdAt: string; updatedAt: string;
}
export interface FranchiseFollowUp {
id: string; actorUserId: string; actorName: string; followUpType: FranchiseFollowUpType;
fromStatus: FranchiseStatus | null; toStatus: FranchiseStatus | null;
note: string; nextFollowUpAt: string | null; createdAt: string;
}
export interface FranchiseApplicationDetail {
application: FranchiseApplication;
followUps: FranchiseFollowUp[];
}
export interface CleaningTask {
id: string;
taskNo: string;
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+5
View File
@@ -56,6 +56,7 @@ import {
registerBusinessStatisticsRoutes,
type BusinessStatisticsRouteOptions
} from './routes/business-statistics.js';
import { registerFranchiseRoutes, type FranchiseRouteOptions } from './routes/franchise.js';
export interface BuildAppOptions {
config?: AppConfig;
@@ -81,6 +82,7 @@ export interface BuildAppOptions {
recharge?: RechargeRouteOptions;
cleaning?: CleaningRouteOptions;
businessStatistics?: BusinessStatisticsRouteOptions;
franchise?: FranchiseRouteOptions;
}
declare module 'fastify' {
@@ -188,6 +190,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.businessStatistics) {
await registerBusinessStatisticsRoutes(app, options.businessStatistics);
}
if (options.franchise) {
await registerFranchiseRoutes(app, options.franchise);
}
return app;
}
+7 -3
View File
@@ -51,7 +51,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062728_m08b_cleaning_payouts.up.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql',
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
'database/migrations/2026081002_m08d_content_asset_scope.up.sql'
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
'database/migrations/2026081003_m08d_franchise_leads.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -84,9 +85,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062728_m08b_cleaning_payouts.verify.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql',
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql'
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
'database/migrations/2026081003_m08d_franchise_leads.verify.sql'
],
down: [
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
@@ -263,7 +266,8 @@ export async function executeMigrationPlan(
1, 7, 5, 1,
4, 1, 1, 1,
2, 1, 1,
1, 1, 1
1, 1, 1,
1, 2, 3, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
@@ -0,0 +1,232 @@
import { randomUUID } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { ManagementActor } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
export type FranchiseStatus = 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'REJECTED' | 'CONVERTED';
export type FollowUpType = 'CALL' | 'WECHAT' | 'MEETING' | 'NOTE' | 'STATUS' | 'ASSIGNMENT';
export interface FranchiseApplicationInput {
tenantId: string;
submittedUserId?: string | null;
city: string;
contactName: string;
contactPhone: string;
message: string;
source: 'MINIAPP' | 'ADMIN' | 'IMPORT';
clientRequestId: string;
traceId: string;
ip: string;
userAgent: string;
}
interface ApplicationRow extends RowDataPacket {
id: string; tenantId: string; applicationNo: string; city: string; contactName: string;
contactPhone: string; message: string; source: string; status: FranchiseStatus;
assigneeUserId: string | null; assigneeName: string | null; submittedUserId: string | null;
nextFollowUpAt: Date | null; closedAt: Date | null; createdAt: Date; updatedAt: Date;
}
interface FollowUpRow extends RowDataPacket {
id: string; actorUserId: string; actorName: string; followUpType: FollowUpType;
fromStatus: FranchiseStatus | null; toStatus: FranchiseStatus | null;
note: string; nextFollowUpAt: Date | null; createdAt: Date;
}
interface StatusRow extends RowDataPacket { status: FranchiseStatus }
export class FranchiseError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class FranchiseRepository {
constructor(private readonly pool: MySqlPool) {}
async submitApplication(input: FranchiseApplicationInput) {
return this.transaction(async (connection) => {
const applicationNo = `FR${Date.now().toString(36).toUpperCase()}${randomUUID().slice(0, 6).toUpperCase()}`;
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_franchise_applications
(tenant_id, application_no, client_request_id, city, contact_name,
contact_phone, message, source, submitted_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
[input.tenantId, applicationNo, input.clientRequestId, input.city, input.contactName,
input.contactPhone, input.message, input.source, input.submittedUserId ?? null]
);
const applicationId = String(result.insertId);
const [rows] = await connection.execute<Array<RowDataPacket & { applicationNo: string }>>(
`SELECT application_no AS applicationNo FROM qipai_franchise_applications
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, applicationId]
);
const created = String(rows[0]?.applicationNo ?? '') === applicationNo;
if (created) {
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, ?, ?, 'FRANCHISE_APPLICATION_SUBMITTED', 'FRANCHISE_APPLICATION',
?, ?, ?, ?, JSON_OBJECT('source', ?, 'city', ?))`,
[input.tenantId, input.submittedUserId ? 'USER' : 'ANONYMOUS',
input.submittedUserId ?? null, applicationId, input.traceId, input.ip,
input.userAgent.slice(0, 255), input.source, input.city]
);
}
return { applicationId, applicationNo: String(rows[0]?.applicationNo ?? applicationNo), idempotent: !created };
});
}
async listApplications(input: {
tenantId: string; page: number; pageSize: number; status?: FranchiseStatus;
assigneeUserId?: string; search?: string;
}) {
const where = ['a.tenant_id = ?', 'a.deleted_at IS NULL'];
const params: Array<string> = [input.tenantId];
if (input.status) { where.push('a.status = ?'); params.push(input.status); }
if (input.assigneeUserId) { where.push('a.assignee_user_id = ?'); params.push(input.assigneeUserId); }
if (input.search) {
where.push('(a.application_no LIKE ? OR a.city LIKE ? OR a.contact_name LIKE ? OR a.contact_phone LIKE ?)');
const term = `%${input.search}%`; params.push(term, term, term, term);
}
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
`SELECT COUNT(*) AS total FROM qipai_franchise_applications a WHERE ${where.join(' AND ')}`,
params
);
const offset = (input.page - 1) * input.pageSize;
const [rows] = await this.pool.execute<ApplicationRow[]>(
`${this.applicationSelect()} WHERE ${where.join(' AND ')}
ORDER BY FIELD(a.status, 'NEW', 'CONTACTED', 'QUALIFIED', 'CONVERTED', 'REJECTED'),
a.next_follow_up_at IS NULL, a.next_follow_up_at, a.id DESC
LIMIT ${input.pageSize} OFFSET ${offset}`,
params
);
return { items: rows.map(normalizeApplication), total: Number(counts[0]?.total ?? 0), page: input.page, pageSize: input.pageSize };
}
async getApplication(tenantId: string, applicationId: string) {
const [rows] = await this.pool.execute<ApplicationRow[]>(
`${this.applicationSelect()} WHERE a.tenant_id = ? AND a.id = ? AND a.deleted_at IS NULL`,
[tenantId, applicationId]
);
if (!rows[0]) throw new FranchiseError('FRANCHISE_APPLICATION_NOT_FOUND');
const [followUps] = await this.pool.execute<FollowUpRow[]>(
`SELECT f.id, f.actor_user_id AS actorUserId, u.nickname AS actorName,
f.follow_up_type AS followUpType, f.from_status AS fromStatus,
f.to_status AS toStatus, f.note, f.next_follow_up_at AS nextFollowUpAt,
f.created_at AS createdAt
FROM qipai_franchise_follow_ups f
INNER JOIN qipai_users u ON u.id = f.actor_user_id
WHERE f.tenant_id = ? AND f.application_id = ? ORDER BY f.id DESC`,
[tenantId, applicationId]
);
return { application: normalizeApplication(rows[0]), followUps: followUps.map((row) => ({ ...row, id: String(row.id), actorUserId: String(row.actorUserId) })) };
}
async assignApplication(actor: ManagementActor, tenantId: string, applicationId: string, assigneeUserId: string | null) {
return this.transaction(async (connection) => {
await this.lockApplication(connection, tenantId, applicationId);
if (assigneeUserId) {
const [users] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_users WHERE tenant_id = ? AND id = ? AND user_type = 'STAFF'
AND status = 'ACTIVE' AND deleted_at IS NULL`,
[tenantId, assigneeUserId]
);
if (!users[0]) throw new FranchiseError('FRANCHISE_ASSIGNEE_INVALID');
}
await connection.execute(
'UPDATE qipai_franchise_applications SET assignee_user_id = ? WHERE tenant_id = ? AND id = ?',
[assigneeUserId, tenantId, applicationId]
);
await this.addEvent(connection, actor, tenantId, applicationId, 'ASSIGNMENT', null, null,
assigneeUserId ? '分派加盟线索负责人' : '取消加盟线索分派', null);
await this.audit(connection, actor, tenantId, 'FRANCHISE_APPLICATION_ASSIGNED', applicationId,
{ assigneeUserId });
return { applicationId, assigneeUserId };
});
}
async addFollowUp(actor: ManagementActor, tenantId: string, applicationId: string, input: {
followUpType: Exclude<FollowUpType, 'ASSIGNMENT' | 'STATUS'>;
note: string; nextFollowUpAt?: Date | null; status?: FranchiseStatus;
}) {
return this.transaction(async (connection) => {
const current = await this.lockApplication(connection, tenantId, applicationId);
const nextStatus = input.status ?? current.status;
if (nextStatus !== current.status && !allowedTransitions[current.status].includes(nextStatus)) {
throw new FranchiseError('FRANCHISE_STATUS_TRANSITION_INVALID');
}
await connection.execute(
`UPDATE qipai_franchise_applications SET status = ?, next_follow_up_at = ?,
closed_at = CASE WHEN ? IN ('REJECTED', 'CONVERTED') THEN UTC_TIMESTAMP(3) ELSE NULL END
WHERE tenant_id = ? AND id = ?`,
[nextStatus, input.nextFollowUpAt ?? null, nextStatus, tenantId, applicationId]
);
const followUpId = await this.addEvent(connection, actor, tenantId, applicationId,
input.followUpType, current.status, nextStatus, input.note, input.nextFollowUpAt ?? null);
await this.audit(connection, actor, tenantId, 'FRANCHISE_FOLLOW_UP_ADDED', applicationId,
{ followUpId, followUpType: input.followUpType, fromStatus: current.status, toStatus: nextStatus });
return { applicationId, followUpId, status: nextStatus };
});
}
private applicationSelect() {
return `SELECT a.id, a.tenant_id AS tenantId, a.application_no AS applicationNo,
a.city, a.contact_name AS contactName, a.contact_phone AS contactPhone,
a.message, a.source, a.status, a.assignee_user_id AS assigneeUserId,
assignee.nickname AS assigneeName, a.submitted_user_id AS submittedUserId,
a.next_follow_up_at AS nextFollowUpAt, a.closed_at AS closedAt,
a.created_at AS createdAt, a.updated_at AS updatedAt
FROM qipai_franchise_applications a
LEFT JOIN qipai_users assignee ON assignee.id = a.assignee_user_id AND assignee.tenant_id = a.tenant_id`;
}
private async lockApplication(connection: PoolConnection, tenantId: string, id: string) {
const [rows] = await connection.execute<StatusRow[]>(
'SELECT status FROM qipai_franchise_applications WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE',
[tenantId, id]
);
if (!rows[0]) throw new FranchiseError('FRANCHISE_APPLICATION_NOT_FOUND');
return rows[0];
}
private async addEvent(connection: PoolConnection, actor: ManagementActor, tenantId: string,
applicationId: string, type: FollowUpType, fromStatus: FranchiseStatus | null,
toStatus: FranchiseStatus | null, note: string, nextFollowUpAt: Date | null) {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_franchise_follow_ups
(tenant_id, application_id, actor_user_id, follow_up_type, from_status,
to_status, note, next_follow_up_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[tenantId, applicationId, actor.userId, type, fromStatus, toStatus, note, nextFollowUpAt]
);
return String(result.insertId);
}
private async audit(connection: PoolConnection, actor: ManagementActor, tenantId: string,
action: string, resourceId: string, metadata: object) {
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata) VALUES (?, 'USER', ?, ?,
'FRANCHISE_APPLICATION', ?, ?, ?, ?, ?)`,
[tenantId, actor.userId, action, resourceId, actor.traceId, actor.ip,
actor.userAgent.slice(0, 255), JSON.stringify(metadata)]
);
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
catch (error) { await connection.rollback(); throw error; }
finally { connection.release(); }
}
}
const allowedTransitions: Record<FranchiseStatus, FranchiseStatus[]> = {
NEW: ['CONTACTED', 'REJECTED'], CONTACTED: ['QUALIFIED', 'REJECTED'],
QUALIFIED: ['CONTACTED', 'CONVERTED', 'REJECTED'], REJECTED: ['CONTACTED'], CONVERTED: []
};
function normalizeApplication(row: ApplicationRow) {
return { ...row, id: String(row.id), tenantId: String(row.tenantId),
assigneeUserId: row.assigneeUserId === null ? null : String(row.assigneeUserId),
submittedUserId: row.submittedUserId === null ? null : String(row.submittedUserId) };
}
+148
View File
@@ -0,0 +1,148 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import { FranchiseError, type FranchiseRepository } from '../franchise/franchise-repository.js';
import { AmbiguousAppTenantError } from '../tenancy/platform-config-repository.js';
import type { PlatformConfigResolver } from './platform-bootstrap.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const status = z.enum(['NEW', 'CONTACTED', 'QUALIFIED', 'REJECTED', 'CONVERTED']);
const contextHeaders = z.object({
'x-wechat-appid': z.string().trim().min(6).max(64),
'tenant-id': id.optional()
});
const applicationSchema = z.object({
city: z.string().trim().min(1).max(64),
contactName: z.string().trim().min(1).max(64),
contactPhone: z.string().trim().regex(/^\+?[0-9 -]{6,32}$/),
message: z.string().trim().max(1000).default(''),
clientRequestId: z.string().trim().min(8).max(128)
});
const listSchema = z.object({
tenantId: id.optional(), page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20), status: status.optional(),
assigneeUserId: id.optional(), search: z.string().trim().max(64).optional()
});
const tenantQuery = z.object({ tenantId: id.optional() });
const assignmentSchema = z.object({ tenantId: id.optional(), assigneeUserId: id.nullable() });
const followUpSchema = z.object({
tenantId: id.optional(), followUpType: z.enum(['CALL', 'WECHAT', 'MEETING', 'NOTE']),
note: z.string().trim().min(1).max(1000), nextFollowUpAt: z.coerce.date().nullable().optional(),
status: status.optional()
});
export interface FranchiseRouteOptions {
repository: Pick<FranchiseRepository, 'submitApplication' | 'listApplications'
| 'getApplication' | 'assignApplication' | 'addFollowUp'>;
platformConfig: PlatformConfigResolver;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerFranchiseRoutes(app: FastifyInstance, options: FranchiseRouteOptions) {
app.post('/app-api/franchise-applications', async (request, reply) => {
const headers = contextHeaders.safeParse(request.headers);
const body = applicationSchema.safeParse(request.body);
if (!headers.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => {
let bootstrap;
try {
bootstrap = await options.platformConfig.resolveBootstrap(
headers.data['x-wechat-appid'], headers.data['tenant-id']
);
} catch (error) {
if (error instanceof AmbiguousAppTenantError) throw new FranchiseError('FRANCHISE_TENANT_SELECTION_REQUIRED');
throw error;
}
if (!bootstrap) throw new FranchiseError('FRANCHISE_TENANT_NOT_FOUND');
const auth = request.headers.authorization
? await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret)
: null;
if (request.headers.authorization && !auth) throw new FranchiseError('FRANCHISE_SESSION_INVALID');
const submittedUserId = auth?.session.tenantId === bootstrap.tenantId ? auth.session.user.id : null;
return reply.status(201).send({ code: 0, data: await options.repository.submitApplication({
tenantId: bootstrap.tenantId, submittedUserId, ...body.data, source: 'MINIAPP',
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? ''
}), traceId: request.traceId });
});
});
app.get('/admin-api/franchise-applications', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const query = listSchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
const targetTenantId = resolveTenant(actor, query.data.tenantId);
if (!targetTenantId) return forbidden(reply, request.traceId);
return handle(reply, request.traceId, async () => ({ code: 0,
data: await options.repository.listApplications({ ...query.data, tenantId: targetTenantId }),
traceId: request.traceId }));
});
app.get('/admin-api/franchise-applications/:id', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const params = z.object({ id }).safeParse(request.params);
const query = tenantQuery.safeParse(request.query);
if (!actor || !params.success || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
const targetTenantId = resolveTenant(actor, query.data.tenantId);
if (!targetTenantId) return forbidden(reply, request.traceId);
return handle(reply, request.traceId, async () => ({ code: 0,
data: await options.repository.getApplication(targetTenantId, params.data.id), traceId: request.traceId }));
});
app.patch('/admin-api/franchise-applications/:id/assignee', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const params = z.object({ id }).safeParse(request.params);
const body = assignmentSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
const targetTenantId = resolveTenant(actor, body.data.tenantId);
if (!targetTenantId) return forbidden(reply, request.traceId);
return handle(reply, request.traceId, async () => ({ code: 0,
data: await options.repository.assignApplication(actor, targetTenantId, params.data.id,
body.data.assigneeUserId), traceId: request.traceId }));
});
app.post('/admin-api/franchise-applications/:id/follow-ups', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const params = z.object({ id }).safeParse(request.params);
const body = followUpSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
const targetTenantId = resolveTenant(actor, body.data.tenantId);
if (!targetTenantId) return forbidden(reply, request.traceId);
return handle(reply, request.traceId, async () => reply.status(201).send({ code: 0,
data: await options.repository.addFollowUp(actor, targetTenantId, params.data.id, body.data),
traceId: request.traceId }));
});
}
async function requireManager(request: FastifyRequest, reply: FastifyReply, options: FranchiseRouteOptions) {
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
if (!auth) { reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId }); return null; }
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
if (!access.capabilities.includes('tenant.manage') && !isPlatform(access)) {
reply.status(403).send({ code: 'FRANCHISE_MANAGEMENT_FORBIDDEN', message: 'Franchise management permission is required.', traceId: request.traceId }); return null;
}
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
}
function resolveTenant(actor: ManagementActor, requested?: string) {
if (!requested || requested === actor.tenantId) return actor.tenantId;
return isPlatform(actor.access) ? requested : null;
}
function isPlatform(access: AccessProfile) { return access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage'); }
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try { return await work(); } catch (error) {
if (!(error instanceof FranchiseError)) throw error;
const statusCode = error.code === 'FRANCHISE_SESSION_INVALID' ? 401
: error.code.endsWith('_NOT_FOUND') ? 404
: error.code.includes('FORBIDDEN') ? 403
: error.code.endsWith('_SELECTION_REQUIRED') ? 409 : 400;
return reply.status(statusCode).send({ code: error.code, message: 'The franchise request is invalid or not allowed.', traceId });
}
}
function invalid(reply: FastifyReply, traceId: string) { return reply.status(400).send({ code: 'INVALID_FRANCHISE_REQUEST', message: 'The franchise request is invalid.', traceId }); }
function forbidden(reply: FastifyReply, traceId: string) { return reply.status(403).send({ code: 'FRANCHISE_TENANT_FORBIDDEN', message: 'The tenant is outside the allowed scope.', traceId }); }
+10 -1
View File
@@ -41,11 +41,13 @@ import { MarketingBenefitService } from './wallets/marketing-benefit-service.js'
import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
import { FranchiseRepository } from './franchise/franchise-repository.js';
const config = loadConfig();
const pool = createMySqlPool(config);
const authRepository = new AuthRepository(pool);
const accessControl = new RbacRepository(pool);
const platformConfigRepository = new PlatformConfigRepository(pool);
const orderManagementRepository = new OrderManagementRepository(pool);
const walletLedgerService = new WalletLedgerService(pool);
const marketingBenefits = new MarketingBenefitService(pool);
@@ -69,7 +71,7 @@ const deviceCommands = new DeviceCommandService(iotMessages, mqtt);
const app = await buildApp({
config,
mqtt,
platformConfigRepository: new PlatformConfigRepository(pool),
platformConfigRepository,
platformManagement: {
repository: new PlatformAdminRepository(pool),
authRepository,
@@ -216,6 +218,13 @@ const app = await buildApp({
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
franchise: {
repository: new FranchiseRepository(pool),
platformConfig: platformConfigRepository,
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
}
});
app.addHook('onClose', async () => {
+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.');
@@ -0,0 +1,3 @@
DELETE FROM qipai_schema_migrations WHERE version = '2026081003';
DROP TABLE IF EXISTS qipai_franchise_follow_ups;
DROP TABLE IF EXISTS qipai_franchise_applications;
@@ -0,0 +1,46 @@
CREATE TABLE IF NOT EXISTS qipai_franchise_applications (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
application_no VARCHAR(64) NOT NULL,
client_request_id VARCHAR(128) NOT NULL,
city VARCHAR(64) NOT NULL,
contact_name VARCHAR(64) NOT NULL,
contact_phone VARCHAR(32) NOT NULL,
message VARCHAR(1000) NOT NULL DEFAULT '',
source VARCHAR(32) NOT NULL DEFAULT 'MINIAPP',
status VARCHAR(32) NOT NULL DEFAULT 'NEW',
assignee_user_id BIGINT UNSIGNED NULL,
submitted_user_id BIGINT UNSIGNED NULL,
next_follow_up_at DATETIME(3) NULL,
closed_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
CONSTRAINT fk_qipai_franchise_application_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_franchise_application_assignee FOREIGN KEY (assignee_user_id) REFERENCES qipai_users(id),
CONSTRAINT fk_qipai_franchise_application_submitter FOREIGN KEY (submitted_user_id) REFERENCES qipai_users(id),
UNIQUE KEY uq_qipai_franchise_application_no (tenant_id, application_no),
UNIQUE KEY uq_qipai_franchise_client_request (tenant_id, client_request_id),
KEY idx_qipai_franchise_queue (tenant_id, status, assignee_user_id, created_at),
KEY idx_qipai_franchise_follow_up (tenant_id, next_follow_up_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_franchise_follow_ups (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
application_id BIGINT UNSIGNED NOT NULL,
actor_user_id BIGINT UNSIGNED NOT NULL,
follow_up_type VARCHAR(32) NOT NULL,
from_status VARCHAR(32) NULL,
to_status VARCHAR(32) NULL,
note VARCHAR(1000) NOT NULL,
next_follow_up_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_franchise_follow_up_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_franchise_follow_up_application FOREIGN KEY (application_id) REFERENCES qipai_franchise_applications(id),
CONSTRAINT fk_qipai_franchise_follow_up_actor FOREIGN KEY (actor_user_id) REFERENCES qipai_users(id),
KEY idx_qipai_franchise_follow_up_history (tenant_id, application_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026081003', 'm08d_franchise_leads');
@@ -0,0 +1,19 @@
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN ('qipai_franchise_applications', 'qipai_franchise_follow_ups')
ORDER BY table_name;
SELECT table_name, index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND ((table_name = 'qipai_franchise_applications'
AND index_name IN ('uq_qipai_franchise_client_request', 'idx_qipai_franchise_queue'))
OR (table_name = 'qipai_franchise_follow_ups'
AND index_name = 'idx_qipai_franchise_follow_up_history'))
GROUP BY table_name, index_name
ORDER BY table_name, index_name;
SELECT version, name
FROM qipai_schema_migrations
WHERE version = '2026081003';
+1
View File
@@ -8,6 +8,7 @@
"pages/profile/index",
"pages/benefits/index",
"pages/recharge/index",
"pages/franchise/apply",
"pages/cleaner/tasks",
"pages/manager/dashboard",
"pages/manager/business",
+27
View File
@@ -0,0 +1,27 @@
const { request, clientRequestId } = require('../../utils/api.js')
Page({
data: {
form: { city: '', contactName: '', contactPhone: '', message: '' },
clientRequestId: '', applicationNo: '', submitting: false, errorMessage: '',
},
onLoad() { this.setData({ clientRequestId: clientRequestId('franchise') }) },
onInput(event) { const field = event.currentTarget.dataset.field; this.setData({ [`form.${field}`]: event.detail.value }) },
async submit() {
const form = this.data.form
if (!form.city.trim() || !form.contactName.trim() || !/^\+?[0-9 -]{6,20}$/.test(form.contactPhone.trim())) {
this.setData({ errorMessage: '请填写意向城市、联系人和有效联系电话' }); return
}
this.setData({ submitting: true, errorMessage: '' })
try {
const response = await request('/franchise-applications', { method: 'POST', data: {
city: form.city.trim(), contactName: form.contactName.trim(),
contactPhone: form.contactPhone.trim(), message: form.message.trim(),
clientRequestId: this.data.clientRequestId,
} })
this.setData({ applicationNo: response.data.applicationNo })
} catch (error) { this.setData({ errorMessage: error.message || '申请提交失败,请稍后重试' }) }
finally { this.setData({ submitting: false }) }
},
backHome() { wx.reLaunch({ url: '/pages/index/index' }) },
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "加盟合作"
}
+15
View File
@@ -0,0 +1,15 @@
<scroll-view class="page" scroll-y>
<view class="container">
<view class="hero"><view class="title">加盟合作</view><view class="subtitle">留下您的开店计划,品牌运营人员会尽快与您联系。</view></view>
<view wx:if="{{applicationNo}}" class="success"><view class="success-title">申请已提交</view><view>申请编号:{{applicationNo}}</view><view class="muted">重复点击不会产生重复申请。</view><button type="primary" bindtap="backHome">返回首页</button></view>
<view wx:else class="form-card">
<view class="label">意向城市</view><input data-field="city" value="{{form.city}}" maxlength="64" placeholder="例如:上海市" bindinput="onInput" />
<view class="label">联系人</view><input data-field="contactName" value="{{form.contactName}}" maxlength="64" placeholder="请输入联系人姓名" bindinput="onInput" />
<view class="label">联系电话</view><input data-field="contactPhone" value="{{form.contactPhone}}" type="number" maxlength="20" placeholder="请输入手机号" bindinput="onInput" />
<view class="label">开店计划或留言</view><textarea data-field="message" value="{{form.message}}" maxlength="1000" placeholder="可填写预计开店时间、区域、预算等" bindinput="onInput" />
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
<button type="primary" loading="{{submitting}}" disabled="{{submitting}}" bindtap="submit">提交申请</button>
<view class="privacy">提交即表示同意运营人员仅将以上信息用于加盟联系与跟进。</view>
</view>
</view>
</scroll-view>
+13
View File
@@ -0,0 +1,13 @@
.page { height: 100vh; background: #f5f6f8; }
.container { padding: 32rpx; }
.hero { padding: 28rpx 8rpx; }
.title { font-size: 44rpx; font-weight: 700; }
.subtitle, .muted, .privacy { margin-top: 12rpx; color: #667085; line-height: 1.6; }
.form-card, .success { padding: 32rpx; border-radius: 20rpx; background: #fff; }
.label { margin: 24rpx 0 12rpx; font-weight: 600; }
input, textarea { box-sizing: border-box; width: 100%; padding: 20rpx 22rpx; border: 1rpx solid #d7dbe0; border-radius: 12rpx; background: #fff; }
textarea { height: 220rpx; }
button { margin-top: 32rpx; }
.success-title { margin-bottom: 16rpx; color: #17823b; font-size: 36rpx; font-weight: 700; }
.error { margin-top: 20rpx; color: #c73535; }
.privacy { font-size: 24rpx; text-align: center; }
+4
View File
@@ -96,6 +96,10 @@ Page({
wx.navigateTo({ url: '/pages/profile/index' })
},
openFranchise() {
wx.navigateTo({ url: '/pages/franchise/apply' })
},
openManager() {
wx.navigateTo({ url: '/pages/manager/dashboard' })
},
+1
View File
@@ -4,6 +4,7 @@
<view class="quick-actions">
<button bindtap="openOrders">我的订单</button>
<button bindtap="openProfile">个人中心</button>
<button bindtap="openFranchise">加盟合作</button>
<button wx:if="{{showManagerEntry}}" type="primary" bindtap="openManager">门店管理</button>
<button wx:if="{{showCleanerEntry}}" bindtap="openCleaner">保洁任务</button>
</view>
+2 -1
View File
@@ -15,12 +15,13 @@
.quick-actions {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 20rpx;
}
.quick-actions button {
flex: 1;
flex: 1 1 30%;
}
.city-search {
+12
View File
@@ -18,6 +18,7 @@ for (const pattern of [
"activeModule = 'devices'",
"activeModule = 'platformApps'",
"activeModule = 'content'",
"activeModule = 'franchise'",
'平台运营总览',
'StoresRoomsPanel',
'OrdersPanel',
@@ -27,6 +28,7 @@ for (const pattern of [
'DevicesPanel',
'PlatformAppsPanel',
'ContentManagementPanel',
'FranchisePanel',
'运营总览',
'savedToken',
'loadCleaningWorkspace'
@@ -208,6 +210,15 @@ assert.match(contentRoutes, /'\/admin-api\/media\/images'/);
assert.match(contentRoutes, /'\/admin-api\/decorations'/);
assert.match(contentRoutes, /'\/admin-api\/advertisements\/:id'/);
const franchise = read('admin/src/components/FranchisePanel.vue');
for (const pattern of ['listFranchiseApplications', 'getFranchiseApplication',
'assignFranchiseApplication', 'addFranchiseFollowUp', '加盟申请与跟进队列', '跟进历史']) {
assert.match(franchise, new RegExp(pattern));
}
const franchiseRoutes = read('backend/src/routes/franchise.ts');
assert.match(franchiseRoutes, /'\/app-api\/franchise-applications'/);
assert.match(franchiseRoutes, /'\/admin-api\/franchise-applications\/:id\/follow-ups'/);
const routes = read('backend/src/routes/business-statistics.ts');
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
@@ -249,6 +260,7 @@ for (const pattern of [
'.content-metrics',
'.asset-gallery',
'.component-editor',
'.franchise-metrics',
'@media (max-width: 980px)',
'@media (max-width: 560px)'
]) {
+7
View File
@@ -142,4 +142,11 @@ for (const pattern of [
assert.match(cleaner, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}
const franchise = read('miniapp/pages/franchise/apply.js')
+ read('miniapp/pages/franchise/apply.wxml')
+ read('miniapp/pages/franchise/apply.json');
for (const pattern of ['/franchise-applications', 'clientRequestId', 'contactPhone', '加盟合作']) {
assert.match(franchise, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}
console.log('PASS: M08-A/M08-B miniapp customer and cleaner pages use fixed domain and real app-api calls.');