feat(M08-D): 补加盟申请与跟进运营
This commit is contained in:
+10
-1
@@ -12,6 +12,7 @@
|
|||||||
<nav class="nav-list" aria-label="后台模块">
|
<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 === '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 === '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
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: activeModule === 'overview' }"
|
:class="{ active: activeModule === 'overview' }"
|
||||||
@@ -124,6 +125,11 @@
|
|||||||
:session="session"
|
:session="session"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FranchisePanel
|
||||||
|
v-else-if="activeModule === 'franchise'"
|
||||||
|
:session="session"
|
||||||
|
/>
|
||||||
|
|
||||||
<StoresRoomsPanel
|
<StoresRoomsPanel
|
||||||
v-else-if="activeModule === 'stores'"
|
v-else-if="activeModule === 'stores'"
|
||||||
:session="session"
|
:session="session"
|
||||||
@@ -319,6 +325,7 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Images,
|
Images,
|
||||||
|
Handshake,
|
||||||
PanelsTopLeft,
|
PanelsTopLeft,
|
||||||
RadioTower,
|
RadioTower,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
@@ -344,6 +351,7 @@ import MembersStaffPanel from './components/MembersStaffPanel.vue';
|
|||||||
import DevicesPanel from './components/DevicesPanel.vue';
|
import DevicesPanel from './components/DevicesPanel.vue';
|
||||||
import PlatformAppsPanel from './components/PlatformAppsPanel.vue';
|
import PlatformAppsPanel from './components/PlatformAppsPanel.vue';
|
||||||
import ContentManagementPanel from './components/ContentManagementPanel.vue';
|
import ContentManagementPanel from './components/ContentManagementPanel.vue';
|
||||||
|
import FranchisePanel from './components/FranchisePanel.vue';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
assignCleaningTask,
|
assignCleaningTask,
|
||||||
@@ -380,7 +388,7 @@ import { money } from './format';
|
|||||||
|
|
||||||
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
|
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
|
||||||
const tokenDraft = ref(savedToken.value);
|
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 activeTab = ref('tasks');
|
||||||
const lastError = ref('');
|
const lastError = ref('');
|
||||||
const lastMessage = ref('');
|
const lastMessage = ref('');
|
||||||
@@ -393,6 +401,7 @@ const activeModuleMeta = computed(() => ({
|
|||||||
overview: { stage: 'M08-D', title: '平台运营总览' },
|
overview: { stage: 'M08-D', title: '平台运营总览' },
|
||||||
platformApps: { stage: 'M08-D', title: '多小程序与租户品牌配置' },
|
platformApps: { stage: 'M08-D', title: '多小程序与租户品牌配置' },
|
||||||
content: { stage: 'M08-D', title: '广告投放与门店装修' },
|
content: { stage: 'M08-D', title: '广告投放与门店装修' },
|
||||||
|
franchise: { stage: 'M08-D', title: '加盟申请与跟进运营' },
|
||||||
stores: { stage: 'M08-D', title: '门店与房间管理' },
|
stores: { stage: 'M08-D', title: '门店与房间管理' },
|
||||||
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
|
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
|
||||||
devices: { stage: 'M08-D', title: '设备资产、拓扑与控制' },
|
devices: { stage: 'M08-D', title: '设备资产、拓扑与控制' },
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import type {
|
|||||||
CleaningTaskMember,
|
CleaningTaskMember,
|
||||||
DecorationComponent,
|
DecorationComponent,
|
||||||
DecorationVersion,
|
DecorationVersion,
|
||||||
|
FranchiseApplication,
|
||||||
|
FranchiseApplicationDetail,
|
||||||
|
FranchiseFollowUpType,
|
||||||
|
FranchiseStatus,
|
||||||
DeviceTopology,
|
DeviceTopology,
|
||||||
DeviceType,
|
DeviceType,
|
||||||
BusinessStatistics,
|
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(
|
export function createStaffUser(
|
||||||
session: ApiSession,
|
session: ApiSession,
|
||||||
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
|
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -518,6 +518,27 @@ export interface Advertisement extends AdvertisementInput {
|
|||||||
imageUrl: string;
|
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 {
|
export interface CleaningTask {
|
||||||
id: string;
|
id: string;
|
||||||
taskNo: string;
|
taskNo: string;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
"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",
|
"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: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": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
registerBusinessStatisticsRoutes,
|
registerBusinessStatisticsRoutes,
|
||||||
type BusinessStatisticsRouteOptions
|
type BusinessStatisticsRouteOptions
|
||||||
} from './routes/business-statistics.js';
|
} from './routes/business-statistics.js';
|
||||||
|
import { registerFranchiseRoutes, type FranchiseRouteOptions } from './routes/franchise.js';
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
config?: AppConfig;
|
config?: AppConfig;
|
||||||
@@ -81,6 +82,7 @@ export interface BuildAppOptions {
|
|||||||
recharge?: RechargeRouteOptions;
|
recharge?: RechargeRouteOptions;
|
||||||
cleaning?: CleaningRouteOptions;
|
cleaning?: CleaningRouteOptions;
|
||||||
businessStatistics?: BusinessStatisticsRouteOptions;
|
businessStatistics?: BusinessStatisticsRouteOptions;
|
||||||
|
franchise?: FranchiseRouteOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@@ -188,6 +190,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||||||
if (options.businessStatistics) {
|
if (options.businessStatistics) {
|
||||||
await registerBusinessStatisticsRoutes(app, options.businessStatistics);
|
await registerBusinessStatisticsRoutes(app, options.businessStatistics);
|
||||||
}
|
}
|
||||||
|
if (options.franchise) {
|
||||||
|
await registerFranchiseRoutes(app, options.franchise);
|
||||||
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026062728_m08b_cleaning_payouts.up.sql',
|
'database/migrations/2026062728_m08b_cleaning_payouts.up.sql',
|
||||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql',
|
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql',
|
||||||
'database/migrations/2026081001_m08c_staff_management_access.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: [
|
verify: [
|
||||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
'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/2026062728_m08b_cleaning_payouts.verify.sql',
|
||||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql',
|
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql',
|
||||||
'database/migrations/2026081001_m08c_staff_management_access.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: [
|
down: [
|
||||||
|
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
|
||||||
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
|
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
|
||||||
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
|
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
|
||||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
|
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
|
||||||
@@ -263,7 +266,8 @@ export async function executeMigrationPlan(
|
|||||||
1, 7, 5, 1,
|
1, 7, 5, 1,
|
||||||
4, 1, 1, 1,
|
4, 1, 1, 1,
|
||||||
2, 1, 1,
|
2, 1, 1,
|
||||||
1, 1, 1
|
1, 1, 1,
|
||||||
|
1, 2, 3, 1
|
||||||
][index] ?? 1;
|
][index] ?? 1;
|
||||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||||
throw new Error(
|
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) };
|
||||||
|
}
|
||||||
@@ -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
@@ -41,11 +41,13 @@ import { MarketingBenefitService } from './wallets/marketing-benefit-service.js'
|
|||||||
import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
|
import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
|
||||||
import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
|
import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
|
||||||
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
|
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
|
||||||
|
import { FranchiseRepository } from './franchise/franchise-repository.js';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const pool = createMySqlPool(config);
|
const pool = createMySqlPool(config);
|
||||||
const authRepository = new AuthRepository(pool);
|
const authRepository = new AuthRepository(pool);
|
||||||
const accessControl = new RbacRepository(pool);
|
const accessControl = new RbacRepository(pool);
|
||||||
|
const platformConfigRepository = new PlatformConfigRepository(pool);
|
||||||
const orderManagementRepository = new OrderManagementRepository(pool);
|
const orderManagementRepository = new OrderManagementRepository(pool);
|
||||||
const walletLedgerService = new WalletLedgerService(pool);
|
const walletLedgerService = new WalletLedgerService(pool);
|
||||||
const marketingBenefits = new MarketingBenefitService(pool);
|
const marketingBenefits = new MarketingBenefitService(pool);
|
||||||
@@ -69,7 +71,7 @@ const deviceCommands = new DeviceCommandService(iotMessages, mqtt);
|
|||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
config,
|
config,
|
||||||
mqtt,
|
mqtt,
|
||||||
platformConfigRepository: new PlatformConfigRepository(pool),
|
platformConfigRepository,
|
||||||
platformManagement: {
|
platformManagement: {
|
||||||
repository: new PlatformAdminRepository(pool),
|
repository: new PlatformAdminRepository(pool),
|
||||||
authRepository,
|
authRepository,
|
||||||
@@ -216,6 +218,13 @@ const app = await buildApp({
|
|||||||
authRepository,
|
authRepository,
|
||||||
accessControl,
|
accessControl,
|
||||||
jwtSecret: config.auth.jwtSecret
|
jwtSecret: config.auth.jwtSecret
|
||||||
|
},
|
||||||
|
franchise: {
|
||||||
|
repository: new FranchiseRepository(pool),
|
||||||
|
platformConfig: platformConfigRepository,
|
||||||
|
authRepository,
|
||||||
|
accessControl,
|
||||||
|
jwtSecret: config.auth.jwtSecret
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
|
|||||||
@@ -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 contentAssetScopeUpSql = read('database/migrations/2026081002_m08d_content_asset_scope.up.sql');
|
||||||
const contentAssetScopeDownSql = read('database/migrations/2026081002_m08d_content_asset_scope.down.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 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 = [
|
const coreTables = [
|
||||||
'qipai_schema_migrations',
|
'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(contentAssetScopeDownSql, /uq_qipai_media_tenant_checksum/);
|
||||||
assert.match(contentAssetScopeVerifySql, /generation_expression/);
|
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.');
|
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, /2026062728_m08b_cleaning_payouts\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.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, /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.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(plan.statements.length >= 11);
|
assert.ok(plan.statements.length >= 11);
|
||||||
|
|
||||||
const verifyPlan = await loadMigrationPlan('verify');
|
const verifyPlan = await loadMigrationPlan('verify');
|
||||||
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
|
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
|
||||||
assert.match(verifyPlan.statements[91], /^SELECT index_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 calls = [];
|
||||||
const fakePool = {
|
const fakePool = {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { RbacRepository } from '../dist/auth/rbac-repository.js';
|
|||||||
import { UserManagementRepository } from '../dist/auth/user-management-repository.js';
|
import { UserManagementRepository } from '../dist/auth/user-management-repository.js';
|
||||||
import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-repository.js';
|
import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-repository.js';
|
||||||
import { ContentRepository, ContentError } from '../dist/content/content-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 { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js';
|
||||||
import { StoreAccessRepository, StoreAccessError } from '../dist/stores/access-repository.js';
|
import { StoreAccessRepository, StoreAccessError } from '../dist/stores/access-repository.js';
|
||||||
import { PricingRepository, PricingError } from '../dist/orders/pricing-repository.js';
|
import { PricingRepository, PricingError } from '../dist/orders/pricing-repository.js';
|
||||||
@@ -60,6 +61,8 @@ const expectedTables = [
|
|||||||
'qipai_device_status_snapshots',
|
'qipai_device_status_snapshots',
|
||||||
'qipai_devices',
|
'qipai_devices',
|
||||||
'qipai_direct_bookings',
|
'qipai_direct_bookings',
|
||||||
|
'qipai_franchise_applications',
|
||||||
|
'qipai_franchise_follow_ups',
|
||||||
'qipai_group_redemptions',
|
'qipai_group_redemptions',
|
||||||
'qipai_group_vouchers',
|
'qipai_group_vouchers',
|
||||||
'qipai_holiday_calendar',
|
'qipai_holiday_calendar',
|
||||||
@@ -129,13 +132,13 @@ async function readMigrationVersions(pool) {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT version, name
|
`SELECT version, name
|
||||||
FROM qipai_schema_migrations
|
FROM qipai_schema_migrations
|
||||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ORDER BY version`,
|
ORDER BY version`,
|
||||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||||
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
||||||
'2026062220', '2026081002']
|
'2026062220', '2026081002', '2026081003']
|
||||||
);
|
);
|
||||||
return rows;
|
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) {
|
async function assertDeviceTopology(pool, context) {
|
||||||
const [adminRows] = await pool.query(
|
const [adminRows] = await pool.query(
|
||||||
`SELECT u.id FROM qipai_users u
|
`SELECT u.id FROM qipai_users u
|
||||||
@@ -2026,7 +2073,8 @@ try {
|
|||||||
{ version: '2026062218', name: 'm05d_profit_sharing' },
|
{ version: '2026062218', name: 'm05d_profit_sharing' },
|
||||||
{ version: '2026062219', name: 'm06b_device_topology' },
|
{ version: '2026062219', name: 'm06b_device_topology' },
|
||||||
{ version: '2026062220', name: 'm06c_iot_messages' },
|
{ 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);
|
await assertTaskDurability(pool);
|
||||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||||
@@ -2034,6 +2082,7 @@ try {
|
|||||||
await assertUserManagement(pool, loginContext);
|
await assertUserManagement(pool, loginContext);
|
||||||
await assertStoreRoomDomain(pool, loginContext);
|
await assertStoreRoomDomain(pool, loginContext);
|
||||||
await assertContentManagement(pool, loginContext);
|
await assertContentManagement(pool, loginContext);
|
||||||
|
await assertFranchiseManagement(pool, loginContext);
|
||||||
await assertStoreDiscovery(pool, loginContext);
|
await assertStoreDiscovery(pool, loginContext);
|
||||||
await assertSceneAndWifiAccess(pool, loginContext);
|
await assertSceneAndWifiAccess(pool, loginContext);
|
||||||
await assertPricingAndReservations(pool, loginContext);
|
await assertPricingAndReservations(pool, loginContext);
|
||||||
@@ -2077,7 +2126,8 @@ try {
|
|||||||
{ version: '2026062218', name: 'm05d_profit_sharing' },
|
{ version: '2026062218', name: 'm05d_profit_sharing' },
|
||||||
{ version: '2026062219', name: 'm06b_device_topology' },
|
{ version: '2026062219', name: 'm06b_device_topology' },
|
||||||
{ version: '2026062220', name: 'm06c_iot_messages' },
|
{ 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);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: second up and verify restored the schema.');
|
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';
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"pages/profile/index",
|
"pages/profile/index",
|
||||||
"pages/benefits/index",
|
"pages/benefits/index",
|
||||||
"pages/recharge/index",
|
"pages/recharge/index",
|
||||||
|
"pages/franchise/apply",
|
||||||
"pages/cleaner/tasks",
|
"pages/cleaner/tasks",
|
||||||
"pages/manager/dashboard",
|
"pages/manager/dashboard",
|
||||||
"pages/manager/business",
|
"pages/manager/business",
|
||||||
|
|||||||
@@ -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' }) },
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "加盟合作"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
@@ -96,6 +96,10 @@ Page({
|
|||||||
wx.navigateTo({ url: '/pages/profile/index' })
|
wx.navigateTo({ url: '/pages/profile/index' })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openFranchise() {
|
||||||
|
wx.navigateTo({ url: '/pages/franchise/apply' })
|
||||||
|
},
|
||||||
|
|
||||||
openManager() {
|
openManager() {
|
||||||
wx.navigateTo({ url: '/pages/manager/dashboard' })
|
wx.navigateTo({ url: '/pages/manager/dashboard' })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<view class="quick-actions">
|
<view class="quick-actions">
|
||||||
<button bindtap="openOrders">我的订单</button>
|
<button bindtap="openOrders">我的订单</button>
|
||||||
<button bindtap="openProfile">个人中心</button>
|
<button bindtap="openProfile">个人中心</button>
|
||||||
|
<button bindtap="openFranchise">加盟合作</button>
|
||||||
<button wx:if="{{showManagerEntry}}" type="primary" bindtap="openManager">门店管理</button>
|
<button wx:if="{{showManagerEntry}}" type="primary" bindtap="openManager">门店管理</button>
|
||||||
<button wx:if="{{showCleanerEntry}}" bindtap="openCleaner">保洁任务</button>
|
<button wx:if="{{showCleanerEntry}}" bindtap="openCleaner">保洁任务</button>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -15,12 +15,13 @@
|
|||||||
|
|
||||||
.quick-actions {
|
.quick-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
margin-bottom: 20rpx;
|
margin-bottom: 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-actions button {
|
.quick-actions button {
|
||||||
flex: 1;
|
flex: 1 1 30%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.city-search {
|
.city-search {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ for (const pattern of [
|
|||||||
"activeModule = 'devices'",
|
"activeModule = 'devices'",
|
||||||
"activeModule = 'platformApps'",
|
"activeModule = 'platformApps'",
|
||||||
"activeModule = 'content'",
|
"activeModule = 'content'",
|
||||||
|
"activeModule = 'franchise'",
|
||||||
'平台运营总览',
|
'平台运营总览',
|
||||||
'StoresRoomsPanel',
|
'StoresRoomsPanel',
|
||||||
'OrdersPanel',
|
'OrdersPanel',
|
||||||
@@ -27,6 +28,7 @@ for (const pattern of [
|
|||||||
'DevicesPanel',
|
'DevicesPanel',
|
||||||
'PlatformAppsPanel',
|
'PlatformAppsPanel',
|
||||||
'ContentManagementPanel',
|
'ContentManagementPanel',
|
||||||
|
'FranchisePanel',
|
||||||
'运营总览',
|
'运营总览',
|
||||||
'savedToken',
|
'savedToken',
|
||||||
'loadCleaningWorkspace'
|
'loadCleaningWorkspace'
|
||||||
@@ -208,6 +210,15 @@ assert.match(contentRoutes, /'\/admin-api\/media\/images'/);
|
|||||||
assert.match(contentRoutes, /'\/admin-api\/decorations'/);
|
assert.match(contentRoutes, /'\/admin-api\/decorations'/);
|
||||||
assert.match(contentRoutes, /'\/admin-api\/advertisements\/:id'/);
|
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');
|
const routes = read('backend/src/routes/business-statistics.ts');
|
||||||
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
|
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
|
||||||
|
|
||||||
@@ -249,6 +260,7 @@ for (const pattern of [
|
|||||||
'.content-metrics',
|
'.content-metrics',
|
||||||
'.asset-gallery',
|
'.asset-gallery',
|
||||||
'.component-editor',
|
'.component-editor',
|
||||||
|
'.franchise-metrics',
|
||||||
'@media (max-width: 980px)',
|
'@media (max-width: 980px)',
|
||||||
'@media (max-width: 560px)'
|
'@media (max-width: 560px)'
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -142,4 +142,11 @@ for (const pattern of [
|
|||||||
assert.match(cleaner, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
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.');
|
console.log('PASS: M08-A/M08-B miniapp customer and cleaner pages use fixed domain and real app-api calls.');
|
||||||
|
|||||||
Reference in New Issue
Block a user