const http = require("http");
|
const crypto = require("crypto");
|
const fs = require("fs");
|
const path = require("path");
|
|
const root = path.resolve(__dirname, "..", "outputs");
|
const dataDir = path.resolve(__dirname, "..", "data");
|
const dataFile = path.join(dataDir, "crm_full_data.json");
|
const port = Number(process.env.PORT || 8090);
|
const accountRoles = ["supervisor", "ops", "sales", "production", "warehouse", "logistics", "finance"];
|
const businessKinds = {
|
salesOrders: { next: "nextSalesOrderId" },
|
salesBills: { next: "nextSalesBillId" },
|
productionTasks: { next: "nextProductionTaskId" },
|
stockItems: { next: "nextStockItemId" },
|
shipments: { next: "nextShipmentId" },
|
receivables: { next: "nextReceivableId" }
|
};
|
const dealStatuses = ["已成交", "待发货/执行", "已交付", "售后维护中", "复购跟进中"];
|
const permissionCodes = new Set([
|
"customers:own",
|
"customers:all",
|
"customers:delete",
|
"customers:assign",
|
"customers:export",
|
"sales:edit",
|
"ops:edit",
|
"orders:view",
|
"orders:edit",
|
"production:edit",
|
"stock:edit",
|
"logistics:edit",
|
"finance:edit",
|
"reports:view",
|
"accounts:manage"
|
]);
|
const accountWriteLocks = new Map();
|
const passwordChangePath = "/api/v1/me/password/change";
|
const passwordChangeBodyLimit = 16 * 1024;
|
const defaultUsers = [
|
{ username: "admin", password: "123456", name: "主管", role: "supervisor" },
|
{ username: "ops1", password: "123456", name: "运营一", role: "ops" },
|
{ username: "sales1", password: "123456", name: "销售一", role: "sales" },
|
{ username: "sales2", password: "123456", name: "销售二", role: "sales" },
|
{ username: "sales3", password: "123456", name: "销售三", role: "sales" }
|
];
|
|
function makeToken(user) {
|
return Buffer.from(`${user.username}:${user.password}`).toString("base64");
|
}
|
|
function defaultPermissions(role) {
|
if (role === "supervisor") return ["customers:all", "customers:delete", "customers:assign", "customers:export", "orders:view", "orders:edit", "production:edit", "stock:edit", "logistics:edit", "finance:edit", "reports:view", "accounts:manage"];
|
if (role === "ops") return ["customers:all", "ops:edit", "reports:view"];
|
if (role === "production") return ["orders:view", "production:edit"];
|
if (role === "warehouse") return ["orders:view", "stock:edit"];
|
if (role === "logistics") return ["orders:view", "logistics:edit"];
|
if (role === "finance") return ["orders:view", "finance:edit", "reports:view"];
|
return ["customers:own", "sales:edit", "orders:view", "orders:edit"];
|
}
|
|
function parsePermissions(value) {
|
const raw = Array.isArray(value)
|
? value
|
: String(value || "")
|
.split(",")
|
.map((item) => item.trim())
|
.filter(Boolean);
|
const selected = raw.filter((code) => permissionCodes.has(code));
|
return [...new Set(selected)];
|
}
|
|
function getUsers(data) {
|
if (!data.users) data.users = defaultUsers.map((user) => ({
|
...user,
|
phone: user.username,
|
status: "active",
|
permissions: defaultPermissions(user.role),
|
createdAt: "2026-07-14 00:00",
|
updatedAt: ""
|
}));
|
for (const user of defaultUsers) {
|
if (!data.users.some((row) => row.username === user.username)) {
|
data.users.push({
|
...user,
|
phone: user.username,
|
status: "active",
|
permissions: defaultPermissions(user.role),
|
createdAt: nowText(),
|
updatedAt: ""
|
});
|
}
|
}
|
for (const user of data.users) {
|
user.status = user.status || "active";
|
user.permissions = user.permissions || defaultPermissions(user.role);
|
user.phone = user.phone || user.username;
|
}
|
return data.users;
|
}
|
|
function ensureBusinessData(data) {
|
for (const [kind, meta] of Object.entries(businessKinds)) {
|
if (!Array.isArray(data[kind])) data[kind] = [];
|
if (!data[meta.next]) {
|
const maxId = data[kind].reduce((max, row) => Math.max(max, Number(row.id) || 0), 0);
|
data[meta.next] = maxId + 1;
|
}
|
}
|
}
|
|
function ensureCustomerData(data) {
|
if (!Array.isArray(data.customers)) data.customers = [];
|
for (const customer of data.customers) {
|
if (!Object.prototype.hasOwnProperty.call(customer, "customerCategory")) {
|
customer.customerCategory = customer.source || "";
|
}
|
}
|
}
|
|
function publicUser(user) {
|
return {
|
username: user.username,
|
name: user.name,
|
phone: user.phone || user.username,
|
role: user.role,
|
status: user.status || "active",
|
permissions: user.permissions || defaultPermissions(user.role),
|
createdAt: user.createdAt || "",
|
updatedAt: user.updatedAt || ""
|
};
|
}
|
|
function accountFromToken(token, data) {
|
if (!token) return null;
|
return getUsers(data).find((user) => user.status !== "disabled" && makeToken(user) === token) || null;
|
}
|
|
function accountFromTokenIncludingDisabled(token, data) {
|
if (!token) return null;
|
return getUsers(data).find((user) => makeToken(user) === token) || null;
|
}
|
|
function bearerTokenFromHeader(req) {
|
const auth = String(req.headers.authorization || "");
|
return auth.startsWith("Bearer ") && auth.length > 7 ? auth.slice(7) : "";
|
}
|
|
function passwordChangeError(status, code, message) {
|
const err = new Error(message);
|
err.status = status;
|
err.passwordChangeCode = code;
|
return err;
|
}
|
|
function dataVersionConflictError() {
|
const err = new Error("数据已被其他请求更新");
|
err.status = 409;
|
err.code = "DATA_VERSION_CONFLICT";
|
return err;
|
}
|
|
function requireAccount(req, url, body = {}, data = load()) {
|
const auth = req.headers.authorization || "";
|
const token = body.token || url.searchParams.get("token") || (auth.startsWith("Bearer ") ? auth.slice(7) : "");
|
const account = accountFromToken(token, data);
|
if (!account) {
|
const err = new Error("请先登录账号");
|
err.status = 401;
|
throw err;
|
}
|
return account;
|
}
|
|
function canSeeCustomer(account, customer) {
|
return account.role === "supervisor" || account.role === "ops" || customer.owner === account.name;
|
}
|
|
function requireCustomerAccess(account, customer) {
|
if (!canSeeCustomer(account, customer)) {
|
const err = new Error("没有权限操作这个客户");
|
err.status = 403;
|
throw err;
|
}
|
}
|
|
function normalizeText(value) {
|
return String(value || "").trim().toLowerCase().replace(/\s+/g, "");
|
}
|
|
function duplicateCandidates(data, body) {
|
const phone = normalizeText(body.phone);
|
const wechat = normalizeText(body.wechat);
|
const platform = normalizeText(body.platformAccount);
|
const leadId = normalizeText(body.leadId);
|
const company = normalizeText(body.company);
|
const name = normalizeText(body.name);
|
return data.customers.map((c) => {
|
const reasons = [];
|
let strong = false;
|
if (phone && normalizeText(c.phone) === phone) { reasons.push("电话相同"); strong = true; }
|
if (wechat && normalizeText(c.wechat) === wechat) { reasons.push("微信相同"); strong = true; }
|
if (platform && normalizeText(c.platformAccount) === platform) { reasons.push("平台ID相同"); strong = true; }
|
if (leadId && (normalizeText(c.leadId) === leadId || normalizeText(c.douyinCustomerId) === leadId)) { reasons.push("线索ID相同"); strong = true; }
|
if (company && normalizeText(c.company) === company) reasons.push("公司名称相同");
|
if (name && normalizeText(c.name) === name) reasons.push("客户名称相同");
|
if (!reasons.length) return null;
|
return {
|
id: c.id,
|
name: c.name,
|
company: c.company,
|
owner: c.owner,
|
firstReceiver: c.firstReceiver,
|
source: c.source,
|
funnel: c.funnel,
|
dealStatus: c.dealStatus,
|
pool: c.pool,
|
lastFollowupAt: c.followups?.[0]?.at || c.lastEffectiveFollowupAt || "",
|
reasons,
|
strong
|
};
|
}).filter(Boolean).sort((a, b) => Number(b.strong) - Number(a.strong)).slice(0, 5);
|
}
|
|
function today() {
|
return new Date().toISOString().slice(0, 10);
|
}
|
|
function addDays(dateText, days) {
|
const d = new Date(`${dateText || today()}T00:00:00`);
|
d.setDate(d.getDate() + days);
|
return d.toISOString().slice(0, 10);
|
}
|
|
function nowText() {
|
const d = new Date();
|
const pad = (n) => String(n).padStart(2, "0");
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
}
|
|
function seedData() {
|
return {
|
nextCustomerId: 6,
|
nextActionId: 1,
|
customers: [
|
{
|
id: 1,
|
name: "王经理",
|
company: "杭州佳源食品厂",
|
phone: "138****9021",
|
wechat: "wangjy",
|
platformAccount: "baidu-wang",
|
source: "百度",
|
owner: "销售一",
|
firstReceiver: "销售一",
|
firstInputAt: "2026-06-18 10:20",
|
firstConsultAt: "2026-06-18 10:10",
|
region: "浙江杭州",
|
scene: "食品车间空间消毒",
|
demand: "臭氧消毒设备",
|
params: "面积 800㎡,层高 4m",
|
intention: "A",
|
level: "A",
|
funnel: "S5 已报价",
|
stage: "报价",
|
dealStatus: "未成交",
|
dealAt: "",
|
dealAmount: "",
|
dealProduct: "",
|
dealQuantity: "",
|
dealUnitPrice: "",
|
dealTotalPrice: "",
|
revisitLevel: "铜",
|
nextDealRevisit: "",
|
protect: "即将到期",
|
protectEnd: "2026-07-08",
|
pool: "正常",
|
poolReason: "",
|
releasedAt: "",
|
previousOwner: "",
|
nextFollowupAt: "2026-07-07",
|
lastEffectiveFollowupAt: "2026-06-27",
|
remark: "客户关注设备稳定性和售后响应。",
|
followups: [
|
{ at: "2026-06-27", user: "销售一", channel: "微信", content: "客户确认车间面积和层高,要求重新核算型号。", next: "2026-07-07", effective: true },
|
{ at: "2026-06-21", user: "销售一", channel: "电话", content: "已按 800㎡ 食品车间方案报价,等待客户内部审批。", next: "2026-06-27", effective: true }
|
],
|
quotes: [
|
{ at: "2026-06-21", user: "销售一", model: "空间消毒设备 A800", amount: "¥128,000", priceType: "标准价", approval: "无需审批" }
|
],
|
dealRevisits: [],
|
actions: []
|
},
|
{
|
id: 2,
|
name: "李工",
|
company: "苏州清源水处理",
|
phone: "136****1188",
|
wechat: "liwater",
|
platformAccount: "aicaigou-li",
|
source: "爱采购",
|
owner: "销售一",
|
firstReceiver: "销售一",
|
firstInputAt: "2026-06-24 15:35",
|
firstConsultAt: "2026-06-24 15:20",
|
region: "江苏苏州",
|
scene: "水处理",
|
demand: "杀菌设备",
|
params: "水量 30T/h",
|
intention: "B",
|
level: "B",
|
funnel: "S4 选型方案",
|
stage: "选型",
|
dealStatus: "未成交",
|
dealAt: "",
|
dealAmount: "",
|
dealProduct: "",
|
dealQuantity: "",
|
dealUnitPrice: "",
|
dealTotalPrice: "",
|
revisitLevel: "铜",
|
nextDealRevisit: "",
|
protect: "保护中",
|
protectEnd: "2026-07-18",
|
pool: "正常",
|
poolReason: "",
|
releasedAt: "",
|
previousOwner: "",
|
nextFollowupAt: "2026-07-01",
|
lastEffectiveFollowupAt: "2026-06-25",
|
remark: "需要确认现场水质和安装空间。",
|
followups: [],
|
quotes: [],
|
dealRevisits: [],
|
actions: []
|
},
|
{
|
id: 3,
|
name: "周主任",
|
company: "常州三院实验室",
|
phone: "137****2190",
|
wechat: "zhoulab",
|
platformAccount: "site-zhou",
|
source: "官网",
|
owner: "销售一",
|
firstReceiver: "销售一",
|
firstInputAt: "2026-04-16 11:48",
|
firstConsultAt: "2026-04-16 11:30",
|
region: "江苏常州",
|
scene: "实验室消毒",
|
demand: "维护和耗材",
|
params: "复购可能",
|
intention: "B",
|
level: "B",
|
funnel: "S7 成交/执行",
|
stage: "成交",
|
dealStatus: "已交付",
|
dealAt: "2026-05-20",
|
dealAmount: "86000",
|
dealProduct: "实验室消毒设备 X2",
|
dealQuantity: "2",
|
dealUnitPrice: "43000",
|
dealTotalPrice: "86000",
|
revisitLevel: "金",
|
nextDealRevisit: "2026-07-20",
|
protect: "保护中",
|
protectEnd: "2026-09-20",
|
pool: "预公海",
|
poolReason: "成交客户需要回访",
|
releasedAt: "",
|
previousOwner: "",
|
nextFollowupAt: "2026-07-09",
|
lastEffectiveFollowupAt: "2026-06-20",
|
remark: "已成交客户,需要跟进耗材复购和设备维护。",
|
followups: [],
|
quotes: [],
|
dealRevisits: [
|
{ at: "2026-06-20", user: "销售一", result: "设备正常,7 月中旬确认耗材需求。", next: "2026-07-20" }
|
],
|
actions: []
|
},
|
{
|
id: 4,
|
name: "陈女士",
|
company: "宁波海润养殖",
|
phone: "135****7786",
|
wechat: "chenhr",
|
platformAccount: "douyin-chen",
|
source: "抖音",
|
owner: "销售三",
|
firstReceiver: "销售三",
|
firstInputAt: "2026-03-28 14:02",
|
firstConsultAt: "2026-03-28 13:55",
|
region: "浙江宁波",
|
scene: "养殖场除味",
|
demand: "除味杀菌",
|
params: "2 个养殖棚",
|
intention: "C",
|
level: "C",
|
funnel: "S8 暂缓/失败",
|
stage: "暂缓",
|
dealStatus: "未成交",
|
dealAt: "",
|
dealAmount: "",
|
dealProduct: "",
|
dealQuantity: "",
|
dealUnitPrice: "",
|
dealTotalPrice: "",
|
revisitLevel: "铁",
|
nextDealRevisit: "",
|
protect: "已过期",
|
protectEnd: "2026-06-10",
|
pool: "公共客户池",
|
poolReason: "保护期已过且无有效推进",
|
releasedAt: "2026-07-01 09:00",
|
previousOwner: "销售三",
|
nextFollowupAt: "2026-06-08",
|
lastEffectiveFollowupAt: "2026-04-01",
|
remark: "客户预算低,后续如有补贴政策可再激活。",
|
followups: [],
|
quotes: [],
|
dealRevisits: [],
|
actions: []
|
}
|
],
|
actions: []
|
};
|
}
|
|
function ensureData() {
|
fs.mkdirSync(dataDir, { recursive: true });
|
if (!fs.existsSync(dataFile)) {
|
fs.writeFileSync(dataFile, JSON.stringify(seedData(), null, 2), "utf8");
|
}
|
}
|
|
function load() {
|
ensureData();
|
const data = JSON.parse(fs.readFileSync(dataFile, "utf8"));
|
ensureBusinessData(data);
|
ensureCustomerData(data);
|
return data;
|
}
|
|
function dataVersion(data) {
|
const value = Number(data && data._version);
|
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
}
|
|
function save(data, expectedVersion = dataVersion(data)) {
|
ensureData();
|
const stored = JSON.parse(fs.readFileSync(dataFile, "utf8"));
|
if (dataVersion(stored) !== expectedVersion) throw dataVersionConflictError();
|
|
const nextData = { ...data, _version: expectedVersion + 1 };
|
const tmpFile = `${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
try {
|
fs.writeFileSync(tmpFile, JSON.stringify(nextData, null, 2), "utf8");
|
fs.renameSync(tmpFile, dataFile);
|
data._version = nextData._version;
|
} finally {
|
if (fs.existsSync(tmpFile)) {
|
try { fs.unlinkSync(tmpFile); } catch (_) { /* best-effort cleanup */ }
|
}
|
}
|
}
|
|
async function withAccountWriteLock(accountKey, task) {
|
const key = String(accountKey);
|
const previous = accountWriteLocks.get(key) || Promise.resolve();
|
let release;
|
const current = new Promise((resolve) => { release = resolve; });
|
accountWriteLocks.set(key, current);
|
await previous;
|
try {
|
return await task();
|
} finally {
|
release();
|
if (accountWriteLocks.get(key) === current) accountWriteLocks.delete(key);
|
}
|
}
|
|
function mutateAccountWithCas(accountKey, expectedVersion, mutator, store = { load, save }) {
|
return withAccountWriteLock(accountKey, () => {
|
const data = store.load();
|
if (dataVersion(data) !== expectedVersion) throw dataVersionConflictError();
|
const result = mutator(data);
|
if (result && typeof result.then === "function") {
|
throw new Error("账号写入回调必须同步完成");
|
}
|
store.save(data, expectedVersion);
|
return result;
|
});
|
}
|
|
function requireSupervisor(account) {
|
if (account.role !== "supervisor") {
|
const err = new Error("只有主管可以管理账号和权限");
|
err.status = 403;
|
throw err;
|
}
|
}
|
|
function httpError(status, message) {
|
const err = new Error(message);
|
err.status = status;
|
return err;
|
}
|
|
function action(data, customer, user, type, title, content) {
|
const row = { id: data.nextActionId++, customerId: customer.id, user, type, title, content, at: nowText() };
|
data.actions.unshift(row);
|
customer.actions = customer.actions || [];
|
customer.actions.unshift(row);
|
}
|
|
function json(res, status, payload) {
|
const body = JSON.stringify(payload);
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
res.end(body);
|
}
|
|
function parseBody(req) {
|
return new Promise((resolve, reject) => {
|
let raw = "";
|
req.on("data", (chunk) => { raw += chunk; });
|
req.on("end", () => {
|
try { resolve(raw ? JSON.parse(raw) : {}); } catch (err) { reject(err); }
|
});
|
});
|
}
|
|
function parsePasswordChangeBody(req) {
|
return new Promise((resolve, reject) => {
|
let raw = "";
|
let size = 0;
|
let tooLarge = false;
|
req.on("data", (chunk) => {
|
size += chunk.length;
|
if (size > passwordChangeBodyLimit) {
|
tooLarge = true;
|
raw = "";
|
} else if (!tooLarge) {
|
raw += chunk;
|
}
|
});
|
req.on("end", () => {
|
if (tooLarge || !raw) {
|
reject(passwordChangeError(400, "INVALID_REQUEST", "请求无效"));
|
return;
|
}
|
try {
|
resolve(JSON.parse(raw));
|
} catch (_) {
|
reject(passwordChangeError(400, "INVALID_REQUEST", "请求无效"));
|
}
|
});
|
req.on("aborted", () => reject(passwordChangeError(400, "INVALID_REQUEST", "请求无效")));
|
req.on("error", () => reject(passwordChangeError(400, "INVALID_REQUEST", "请求无效")));
|
});
|
}
|
|
function validatePasswordChangeEnvelope(req, url) {
|
const contentType = String(req.headers["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
|
if (contentType !== "application/json" || !url.searchParams.keys().next().done) {
|
throw passwordChangeError(400, "INVALID_REQUEST", "请求无效");
|
}
|
}
|
|
function validatePasswordChangePayload(body) {
|
if (!body || Array.isArray(body) || typeof body !== "object") {
|
throw passwordChangeError(400, "INVALID_REQUEST", "请求无效");
|
}
|
const keys = Object.keys(body);
|
const allowed = new Set(["currentPassword", "newPassword"]);
|
if (keys.length !== 2 || keys.some((key) => !allowed.has(key))
|
|| typeof body.currentPassword !== "string" || typeof body.newPassword !== "string") {
|
throw passwordChangeError(400, "INVALID_REQUEST", "请求无效");
|
}
|
}
|
|
function passwordChangeRequestId() {
|
return `pwd-${crypto.randomUUID()}`;
|
}
|
|
function writePasswordChangeError(res, err, requestId) {
|
const body = JSON.stringify({
|
error: {
|
code: err.passwordChangeCode,
|
message: err.message,
|
requestId
|
}
|
});
|
res.writeHead(err.status, {
|
"Content-Type": "application/json; charset=utf-8",
|
"Cache-Control": "no-store"
|
});
|
res.end(body);
|
}
|
|
function normalizedPasswordChangeError(err) {
|
if (err && err.passwordChangeCode) return err;
|
if (err && err.code === "DATA_VERSION_CONFLICT") {
|
return passwordChangeError(409, "PASSWORD_UPDATE_CONFLICT", "密码状态已变化,请重新登录");
|
}
|
if (err instanceof SyntaxError || (err && typeof err.code === "string" && /^E[A-Z]+$/.test(err.code))) {
|
return passwordChangeError(503, "PASSWORD_CHANGE_UNAVAILABLE", "密码修改暂不可用");
|
}
|
return passwordChangeError(500, "INTERNAL_ERROR", "服务暂时无法处理请求");
|
}
|
|
async function handlePasswordChange(req, res, url, store = { load, save }) {
|
const requestId = passwordChangeRequestId();
|
try {
|
validatePasswordChangeEnvelope(req, url);
|
const body = await parsePasswordChangeBody(req);
|
validatePasswordChangePayload(body);
|
|
let initialData;
|
try {
|
initialData = store.load();
|
} catch (err) {
|
throw normalizedPasswordChangeError(err);
|
}
|
const token = bearerTokenFromHeader(req);
|
const authenticated = accountFromTokenIncludingDisabled(token, initialData);
|
if (!authenticated) {
|
throw passwordChangeError(401, "AUTHENTICATION_REQUIRED", "请重新登录");
|
}
|
const expectedVersion = dataVersion(initialData);
|
|
await mutateAccountWithCas(authenticated.username, expectedVersion, (latestData) => {
|
const latest = getUsers(latestData).find((row) => row.username === authenticated.username);
|
if (!latest) {
|
throw passwordChangeError(409, "PASSWORD_UPDATE_CONFLICT", "密码状态已变化,请重新登录");
|
}
|
if (latest.status === "disabled") {
|
throw passwordChangeError(403, "ACCOUNT_DISABLED", "账号不可用");
|
}
|
if (makeToken(latest) !== token) {
|
throw passwordChangeError(409, "PASSWORD_UPDATE_CONFLICT", "密码状态已变化,请重新登录");
|
}
|
if (latest.password !== body.currentPassword) {
|
throw passwordChangeError(422, "CURRENT_PASSWORD_INCORRECT", "当前密码不正确");
|
}
|
const codePointLength = Array.from(body.newPassword).length;
|
if (codePointLength < 8 || codePointLength > 64) {
|
throw passwordChangeError(422, "PASSWORD_LENGTH_INVALID", "新密码长度必须为8至64个字符");
|
}
|
if (body.newPassword === body.currentPassword) {
|
throw passwordChangeError(422, "NEW_PASSWORD_SAME_AS_CURRENT", "新密码不能与当前密码相同");
|
}
|
latest.password = body.newPassword;
|
latest.updatedAt = nowText();
|
}, store);
|
|
res.writeHead(204, { "Cache-Control": "no-store" });
|
res.end();
|
} catch (err) {
|
writePasswordChangeError(res, normalizedPasswordChangeError(err), requestId);
|
}
|
}
|
|
function remindersFor(data, account) {
|
const visible = data.customers.filter((c) => canSeeCustomer(account, c));
|
const rows = [];
|
data.manualReminders = data.manualReminders || [];
|
for (const r of data.manualReminders.filter((r) => r.status !== "done")) {
|
if (account.role === "supervisor" || r.user === account.name) {
|
rows.push({ id: r.id, customerId: r.customerId, customer: r.customer || "手动提醒", type: "手动提醒", text: `${r.dueDate || ""} ${r.content || ""}`, manual: true });
|
}
|
}
|
for (const c of visible) {
|
const isDeal = dealStatuses.includes(c.dealStatus);
|
if (!isDeal && c.nextFollowupAt && c.nextFollowupAt <= today()) {
|
const overdueDays = Math.max(0, Math.floor((new Date(today() + "T00:00:00") - new Date(c.nextFollowupAt + "T00:00:00")) / 86400000));
|
const type = overdueDays >= 7 ? "逾期7天主管升级" : overdueDays >= 3 ? "逾期3天提醒" : c.nextFollowupAt === today() ? "今日待跟进" : "超期未跟进";
|
rows.push({ customerId: c.id, customer: c.name, type, text: `${c.company},下次跟进时间 ${c.nextFollowupAt},已逾期${overdueDays}天` });
|
}
|
if (c.pool === "预公海") rows.push({ customerId: c.id, customer: c.name, type: "预公海提醒", text: c.poolReason || "需要补充有效跟进或回访" });
|
if (isDeal) {
|
if ((!c.dealRevisits || !c.dealRevisits.length) && !c.nextDealRevisit) {
|
rows.push({ customerId: c.id, customer: c.name, type: "成交回访待登记", text: `${c.company} 已成交,但还没有成交回访记录,请补充首次回访。` });
|
} else if (c.nextDealRevisit && c.nextDealRevisit <= today()) {
|
rows.push({ customerId: c.id, customer: c.name, type: "成交回访", text: `成交客户需要回访,计划时间 ${c.nextDealRevisit}` });
|
}
|
}
|
}
|
return rows;
|
}
|
|
async function handleApi(req, res, options = {}) {
|
const url = new URL(req.url, "http://127.0.0.1");
|
if (req.method === "POST" && url.pathname === passwordChangePath) {
|
await handlePasswordChange(req, res, url, options.passwordStore || { load, save });
|
return;
|
}
|
const data = load();
|
|
if (req.method === "POST" && url.pathname === "/api/login") {
|
const body = await parseBody(req);
|
const account = getUsers(data).find((user) => user.status !== "disabled" && user.username === body.username && user.password === body.password);
|
if (!account) {
|
json(res, 401, { error: "账号或密码不正确" });
|
return;
|
}
|
json(res, 200, {
|
token: makeToken(account),
|
user: publicUser(account),
|
users: getUsers(data).map(publicUser)
|
});
|
return;
|
}
|
|
if (req.method === "GET" && url.pathname === "/api/state") {
|
const account = requireAccount(req, url, {}, data);
|
const customers = data.customers.filter((c) => canSeeCustomer(account, c));
|
json(res, 200, {
|
...data,
|
customers,
|
users: undefined,
|
allUsers: getUsers(data).map(publicUser),
|
currentAccount: publicUser(account),
|
reminders: remindersFor(data, account),
|
today: today()
|
});
|
return;
|
}
|
|
if (req.method === "GET" && url.pathname === "/api/export.csv") {
|
const account = requireAccount(req, url, {}, data);
|
const headers = ["序号", "客户", "公司", "电话", "微信", "平台账号", "来源", "客户类别", "免费/付费", "搜索词", "关键词", "时段", "经销商/终端", "客户地区", "内容/广告", "投放账号", "内容链接", "运营负责人", "线索ID", "负责人", "首次录入人", "首次录入时间", "阶段", "成交状态", "成交客户星级", "成交产品", "数量", "单价", "总价", "公海状态", "下次跟进", "备注"];
|
const lines = [headers];
|
data.customers.filter((c) => canSeeCustomer(account, c)).forEach((c, index) => {
|
lines.push([index + 1, c.name, c.company, c.phone, c.wechat, c.platformAccount, c.source, c.customerCategory || c.source, c.trafficCostType, c.searchTerm, c.keyword, c.trafficTimeSlot, c.customerType, c.opRegion || c.region, c.contentName, c.sourceAccount, c.contentLink, c.opsOwner, c.leadId, c.owner, c.firstReceiver, c.firstInputAt, c.funnel, c.dealStatus, c.revisitLevel, c.dealProduct, c.dealQuantity, c.dealUnitPrice, c.dealTotalPrice || c.dealAmount, c.pool, c.nextFollowupAt, c.remark]);
|
});
|
const csv = lines.map((row) => row.map((cell) => `"${String(cell || "").replace(/"/g, '""')}"`).join(",")).join("\r\n");
|
res.writeHead(200, {
|
"Content-Type": "text/csv; charset=utf-8",
|
"Content-Disposition": "attachment; filename=sales-crm-customers.csv"
|
});
|
res.end("\ufeff" + csv);
|
return;
|
}
|
|
if (req.method !== "POST") {
|
json(res, 405, { error: "Method not allowed" });
|
return;
|
}
|
|
const body = await parseBody(req);
|
const account = requireAccount(req, url, body, data);
|
const user = account.name;
|
|
if (url.pathname === "/api/duplicates") {
|
json(res, 200, { duplicates: duplicateCandidates(data, body) });
|
return;
|
}
|
|
if (url.pathname === "/api/accounts/save") {
|
requireSupervisor(account);
|
const username = String(body.username || body.phone || "").trim();
|
const phone = String(body.phone || username).trim();
|
const name = String(body.name || "").trim();
|
const password = String(body.password || "").trim();
|
const roleValue = String(body.role || "").trim();
|
const status = body.status === "disabled" ? "disabled" : "active";
|
const permissions = Object.prototype.hasOwnProperty.call(body, "permissions")
|
? parsePermissions(body.permissions)
|
: defaultPermissions(roleValue);
|
if (!username || !name || !accountRoles.includes(roleValue)) {
|
json(res, 400, { error: "请填写账号、姓名和岗位" });
|
return;
|
}
|
const result = await mutateAccountWithCas(username, dataVersion(data), (latestData) => {
|
const latestActor = getUsers(latestData).find((item) => item.username === account.username && item.status !== "disabled");
|
if (!latestActor) throw httpError(401, "请先登录账号");
|
requireSupervisor(latestActor);
|
|
const accountRows = getUsers(latestData);
|
let row = accountRows.find((item) => item.username === username);
|
if (!row && accountRows.some((item) => item.phone === phone)) {
|
throw httpError(409, "这个手机号已经存在账号");
|
}
|
if (!row && !password) throw httpError(400, "新增账号必须设置初始密码");
|
if (row && row.username === latestActor.username && status === "disabled") {
|
throw httpError(400, "不能停用当前登录的主管账号");
|
}
|
if (row) {
|
row.name = name;
|
row.phone = phone;
|
row.role = roleValue;
|
row.status = status;
|
row.permissions = permissions;
|
if (password) row.password = password;
|
row.updatedAt = nowText();
|
} else {
|
row = {
|
username,
|
phone,
|
password,
|
name,
|
role: roleValue,
|
status,
|
permissions,
|
createdAt: nowText(),
|
updatedAt: ""
|
};
|
accountRows.push(row);
|
}
|
return { account: publicUser(row), accounts: accountRows.map(publicUser) };
|
});
|
json(res, 200, result);
|
return;
|
}
|
|
if (url.pathname === "/api/accounts/status") {
|
requireSupervisor(account);
|
const username = String(body.username || "").trim();
|
const status = body.status === "disabled" ? "disabled" : "active";
|
if (!username || username === account.username) {
|
json(res, 400, { error: "不能修改当前登录账号状态" });
|
return;
|
}
|
const result = await mutateAccountWithCas(username, dataVersion(data), (latestData) => {
|
const latestActor = getUsers(latestData).find((item) => item.username === account.username && item.status !== "disabled");
|
if (!latestActor) throw httpError(401, "请先登录账号");
|
requireSupervisor(latestActor);
|
|
const accountRows = getUsers(latestData);
|
const row = accountRows.find((item) => item.username === username);
|
if (!row) throw httpError(404, "账号不存在");
|
row.status = status;
|
row.updatedAt = nowText();
|
return { account: publicUser(row), accounts: accountRows.map(publicUser) };
|
});
|
json(res, 200, result);
|
return;
|
}
|
|
if (url.pathname === "/api/accounts/transfer") {
|
requireSupervisor(account);
|
const from = String(body.from || "").trim();
|
const to = String(body.to || "").trim();
|
if (!from || !to || from === to) {
|
json(res, 400, { error: "请选择原负责人和新负责人" });
|
return;
|
}
|
const users = getUsers(data);
|
const target = users.find((item) => item.name === to && item.role === "sales" && item.status !== "disabled");
|
if (!target) {
|
json(res, 400, { error: "新负责人必须是启用中的销售账号" });
|
return;
|
}
|
let count = 0;
|
for (const customer of data.customers) {
|
if (customer.owner === from) {
|
customer.previousOwner = customer.owner;
|
customer.owner = to;
|
customer.pool = customer.pool || "正常";
|
action(data, customer, user, "transfer_owner", "客户负责人转接", `${from} -> ${to}`);
|
count++;
|
}
|
}
|
save(data);
|
json(res, 200, { ok: true, count });
|
return;
|
}
|
|
if (url.pathname === "/api/reminders") {
|
data.manualReminders = data.manualReminders || [];
|
const reminder = { id: Date.now(), customerId: Number(body.customerId) || null, customer: body.customer || "", user, dueDate: body.dueDate || today(), content: body.content || "", status: "open", createdAt: nowText() };
|
data.manualReminders.unshift(reminder);
|
save(data);
|
json(res, 200, { reminder });
|
return;
|
}
|
|
if (url.pathname === "/api/reminders/complete") {
|
data.manualReminders = data.manualReminders || [];
|
const reminder = data.manualReminders.find((r) => r.id === Number(body.id));
|
if (reminder) reminder.status = "done";
|
save(data);
|
json(res, 200, { reminder });
|
return;
|
}
|
|
if (url.pathname === "/api/run-rules") {
|
const now = new Date(today() + "T00:00:00");
|
for (const c of data.customers) {
|
if (c.pool === "公共客户池") continue;
|
if (dealStatuses.includes(c.dealStatus)) continue;
|
const base = c.lastEffectiveFollowupAt || c.firstInputAt?.slice(0, 10);
|
if (!base) continue;
|
const days = Math.floor((now - new Date(base + "T00:00:00")) / 86400000);
|
if (days >= 30) {
|
c.previousOwner = c.owner;
|
c.pool = "公共客户池";
|
c.poolReason = `自动公海:${days}天无有效跟进`;
|
c.releasedAt = nowText();
|
c.protect = "已过期";
|
action(data, c, "系统", "auto_public_pool", "自动进入公海", c.poolReason);
|
}
|
}
|
save(data);
|
json(res, 200, { ok: true });
|
return;
|
}
|
|
if (url.pathname === "/api/customers/delete") {
|
if (account.role !== "supervisor") {
|
json(res, 403, { error: "只有主管可以删除客户" });
|
return;
|
}
|
const index = data.customers.findIndex((c) => c.id === Number(body.customerId));
|
if (index < 0) {
|
json(res, 404, { error: "客户不存在" });
|
return;
|
}
|
const [removed] = data.customers.splice(index, 1);
|
action(data, removed, user, "delete_customer", "删除客户", `${removed.name} / ${removed.company}`);
|
save(data);
|
json(res, 200, { ok: true });
|
return;
|
}
|
|
if (url.pathname === "/api/customers") {
|
if (!body.name || !body.company) {
|
json(res, 400, { error: "客户姓名和公司名称必填" });
|
return;
|
}
|
if (!body.phone && !body.wechat && !body.platformAccount && !body.leadId) {
|
json(res, 400, { error: "手机号、微信、平台账号、线索ID至少填写一项" });
|
return;
|
}
|
const strongDup = duplicateCandidates(data, body).find((row) => row.strong || row.reasons.includes("公司名称相同"));
|
if (strongDup) {
|
json(res, 409, { error: `发现强重复客户:${strongDup.name} / ${strongDup.company},不能重复录入`, duplicate: strongDup });
|
return;
|
}
|
const customer = {
|
id: data.nextCustomerId++,
|
name: body.name || "",
|
company: body.company || "",
|
phone: body.phone || "",
|
wechat: body.wechat || "",
|
platformAccount: body.platformAccount || "",
|
source: body.source || "",
|
customerCategory: body.customerCategory || body.source || "",
|
trafficCostType: body.trafficCostType || "免费",
|
searchTerm: body.searchTerm || "",
|
keyword: body.keyword || "",
|
trafficTimeSlot: body.trafficTimeSlot || "",
|
customerType: body.customerType || "",
|
opRegion: body.opRegion || body.region || "",
|
trafficType: body.trafficType || "",
|
marketingType: body.marketingType || "",
|
interactionScene: body.interactionScene || "",
|
conversionStatus: body.conversionStatus || "",
|
leadId: body.leadId || "",
|
douyinCustomerId: body.douyinCustomerId || "",
|
sourceAccount: body.sourceAccount || "",
|
contentName: body.contentName || "",
|
contentLink: body.contentLink || "",
|
campaignId: body.campaignId || "",
|
unitId: body.unitId || "",
|
unitName: body.unitName || "",
|
isPrivateLead: body.isPrivateLead || "",
|
leadCreatedAt: body.leadCreatedAt || "",
|
lastLeaveAt: body.lastLeaveAt || "",
|
leaveCount: body.leaveCount || "",
|
opsOwner: body.opsOwner || "",
|
owner: account.role === "sales" ? user : (body.owner || "销售一"),
|
firstReceiver: body.firstReceiver || user,
|
firstInputAt: nowText(),
|
firstConsultAt: body.firstConsultAt || nowText(),
|
region: body.region || "",
|
scene: body.scene || "",
|
demand: body.demand || "",
|
params: body.params || "",
|
intention: body.intention || "C",
|
level: body.intention || "C",
|
funnel: body.funnel || "S1 新线索",
|
stage: body.stage || "初询",
|
dealStatus: body.dealStatus || "未成交",
|
dealAt: "",
|
dealAmount: "",
|
dealProduct: "",
|
dealQuantity: "",
|
dealUnitPrice: "",
|
dealTotalPrice: "",
|
revisitLevel: body.revisitLevel || "铜",
|
nextDealRevisit: "",
|
protect: "保护中",
|
protectEnd: body.protectEnd || "",
|
pool: "正常",
|
poolReason: "",
|
releasedAt: "",
|
previousOwner: "",
|
nextFollowupAt: body.nextFollowupAt || "",
|
lastEffectiveFollowupAt: "",
|
remark: body.remark || "",
|
followups: [],
|
quotes: [],
|
dealRevisits: [],
|
actions: []
|
};
|
action(data, customer, user, "create_customer", "新增客户", "录入客户并生成首次录入记录");
|
data.customers.unshift(customer);
|
save(data);
|
json(res, 200, { customer });
|
return;
|
}
|
|
if (url.pathname === "/api/business/create") {
|
const kind = String(body.kind || "");
|
const meta = businessKinds[kind];
|
if (!meta) {
|
json(res, 400, { error: "业务类型不正确" });
|
return;
|
}
|
const row = {
|
...(body.row || {}),
|
id: data[meta.next]++,
|
createdBy: user,
|
createdAt: nowText(),
|
updatedAt: nowText()
|
};
|
if (!row.status) row.status = "待处理";
|
data[kind].unshift(row);
|
save(data);
|
json(res, 200, { row });
|
return;
|
}
|
|
if (url.pathname === "/api/business/update") {
|
const kind = String(body.kind || "");
|
const meta = businessKinds[kind];
|
if (!meta) {
|
json(res, 400, { error: "业务类型不正确" });
|
return;
|
}
|
const row = data[kind].find((item) => item.id === Number(body.id));
|
if (!row) {
|
json(res, 404, { error: "记录不存在" });
|
return;
|
}
|
Object.assign(row, body.patch || {}, { updatedBy: user, updatedAt: nowText() });
|
save(data);
|
json(res, 200, { row });
|
return;
|
}
|
|
if (url.pathname === "/api/business/delete") {
|
const kind = String(body.kind || "");
|
if (!businessKinds[kind]) {
|
json(res, 400, { error: "业务类型不正确" });
|
return;
|
}
|
const index = data[kind].findIndex((item) => item.id === Number(body.id));
|
if (index < 0) {
|
json(res, 404, { error: "记录不存在" });
|
return;
|
}
|
const [row] = data[kind].splice(index, 1);
|
save(data);
|
json(res, 200, { row });
|
return;
|
}
|
|
const customer = data.customers.find((c) => c.id === Number(body.customerId));
|
if (!customer) {
|
json(res, 404, { error: "客户不存在" });
|
return;
|
}
|
requireCustomerAccess(account, customer);
|
|
if (url.pathname === "/api/customers/update") {
|
const fields = ["name", "company", "phone", "wechat", "platformAccount", "source", "customerCategory", "trafficCostType", "searchTerm", "keyword", "trafficTimeSlot", "customerType", "opRegion", "trafficType", "marketingType", "interactionScene", "conversionStatus", "leadId", "douyinCustomerId", "sourceAccount", "contentName", "contentLink", "campaignId", "unitId", "unitName", "isPrivateLead", "leadCreatedAt", "lastLeaveAt", "leaveCount", "opsOwner", "owner", "firstReceiver", "firstInputAt", "region", "scene", "demand", "params", "intention", "funnel", "stage", "dealStatus", "pool", "poolReason", "nextFollowupAt", "remark"];
|
for (const field of fields) {
|
if (field === "owner" && account.role !== "supervisor") continue;
|
if (Object.prototype.hasOwnProperty.call(body, field)) customer[field] = body[field] || "";
|
}
|
action(data, customer, user, "update_customer", "编辑客户信息", "修改客户基础资料");
|
save(data);
|
json(res, 200, { customer });
|
return;
|
}
|
|
if (url.pathname === "/api/followups") {
|
const row = { at: body.at || today(), user, channel: body.channel || "电话", content: body.content || "", next: body.nextFollowupAt || "", effective: !!body.effective };
|
customer.followups.unshift(row);
|
customer.nextFollowupAt = row.next || customer.nextFollowupAt;
|
if (row.effective) customer.lastEffectiveFollowupAt = row.at;
|
if (body.funnel) {
|
const before = customer.funnel;
|
customer.funnel = body.funnel;
|
customer.stage = body.stage || body.funnel.replace(/^S\d+\s*/, "");
|
if (before !== customer.funnel) {
|
action(data, customer, user, "change_funnel_stage", "调整客户阶段", `${before || "未设置"} -> ${customer.funnel}`);
|
}
|
}
|
action(data, customer, user, "add_followup", "新增跟进记录", row.content);
|
save(data);
|
json(res, 200, { customer });
|
return;
|
}
|
|
if (url.pathname === "/api/quotes") {
|
customer.quotes = customer.quotes || [];
|
const quantity = Number(body.quantity || 0);
|
const unitPrice = Number(body.unitPrice || 0);
|
const totalPrice = body.totalPrice || (quantity && unitPrice ? String(quantity * unitPrice) : "");
|
const row = {
|
at: body.at || today(),
|
user,
|
model: body.model || "",
|
quantity: body.quantity || "",
|
unitPrice: body.unitPrice || "",
|
amount: totalPrice,
|
priceType: body.priceType || "标准价",
|
approval: body.approval || "无需审批",
|
quoteNo: body.quoteNo || "",
|
remark: body.remark || ""
|
};
|
customer.quotes.unshift(row);
|
action(data, customer, user, "add_quote", "新增报价记录", `${row.model} ${row.amount}`);
|
save(data);
|
json(res, 200, { customer });
|
return;
|
}
|
|
if (url.pathname === "/api/followups/update") {
|
const index = Number(body.followupIndex);
|
if (!customer.followups || !customer.followups[index]) {
|
json(res, 404, { error: "跟进记录不存在" });
|
return;
|
}
|
customer.followups[index] = {
|
...customer.followups[index],
|
channel: body.channel || customer.followups[index].channel,
|
content: body.content || "",
|
next: body.nextFollowupAt || "",
|
effective: !!body.effective
|
};
|
action(data, customer, user, "update_followup", "编辑跟进记录", customer.followups[index].content);
|
save(data);
|
json(res, 200, { customer });
|
return;
|
}
|
|
if (url.pathname === "/api/followups/delete") {
|
const index = Number(body.followupIndex);
|
if (!customer.followups || !customer.followups[index]) {
|
json(res, 404, { error: "跟进记录不存在" });
|
return;
|
}
|
const [removed] = customer.followups.splice(index, 1);
|
action(data, customer, user, "delete_followup", "删除跟进记录", removed.content || "");
|
save(data);
|
json(res, 200, { customer });
|
return;
|
}
|
|
if (url.pathname === "/api/deal") {
|
const nextStatus = body.dealStatus || "未成交";
|
customer.dealStatus = nextStatus;
|
if (nextStatus === "未成交") {
|
customer.stage = body.stage || "初询";
|
customer.funnel = body.funnel || "S2 已联系";
|
customer.dealAt = "";
|
customer.dealAmount = "";
|
customer.dealProduct = "";
|
customer.dealQuantity = "";
|
customer.dealUnitPrice = "";
|
customer.dealTotalPrice = "";
|
customer.nextDealRevisit = "";
|
action(data, customer, user, "update_deal_status", "修改成交状态", "已改为未成交,并清空成交信息");
|
} else {
|
customer.stage = "成交";
|
customer.funnel = "S7 成交/执行";
|
customer.dealAt = body.dealAt || today();
|
customer.dealAmount = body.dealAmount || "";
|
customer.dealProduct = body.dealProduct || "";
|
customer.dealQuantity = body.dealQuantity || "";
|
customer.dealUnitPrice = body.dealUnitPrice || "";
|
customer.dealTotalPrice = body.dealTotalPrice || body.dealAmount || "";
|
customer.revisitLevel = body.revisitLevel || customer.revisitLevel || "铜";
|
customer.nextDealRevisit = body.nextDealRevisit || addDays(customer.dealAt, 7);
|
action(data, customer, user, "mark_deal", "修改成交状态", `${nextStatus} ${customer.dealProduct} ${customer.dealAmount}`);
|
}
|
save(data);
|
json(res, 200, { customer });
|
return;
|
}
|
|
if (url.pathname === "/api/deal-revisits") {
|
const row = { at: body.at || today(), user, result: body.result || "", next: body.nextDealRevisit || "" };
|
customer.dealRevisits.unshift(row);
|
customer.nextDealRevisit = row.next || customer.nextDealRevisit;
|
action(data, customer, user, "add_deal_revisit", "新增成交回访", row.result);
|
save(data);
|
json(res, 200, { customer });
|
return;
|
}
|
|
if (url.pathname === "/api/release") {
|
if (account.role !== "supervisor") {
|
json(res, 403, { error: "只有主管可以释放客户到公海" });
|
return;
|
}
|
customer.previousOwner = customer.owner;
|
customer.pool = "公共客户池";
|
customer.poolReason = body.reason || "主管释放";
|
customer.releasedAt = nowText();
|
customer.protect = "已过期";
|
action(data, customer, user, "release_to_pool", "释放到公海", customer.poolReason);
|
save(data);
|
json(res, 200, { customer });
|
return;
|
}
|
|
if (url.pathname === "/api/assign") {
|
if (account.role !== "supervisor") {
|
json(res, 403, { error: "只有主管可以分配公海客户" });
|
return;
|
}
|
const to = body.owner || "销售一";
|
const target = getUsers(data).find((item) => item.name === to && item.role === "sales" && item.status !== "disabled");
|
if (!target) {
|
json(res, 400, { error: "只能分配给启用中的销售账号" });
|
return;
|
}
|
customer.previousOwner = customer.owner;
|
customer.owner = to;
|
customer.pool = "正常";
|
customer.poolReason = "";
|
action(data, customer, user, "assign_from_pool", "公海重新分配", `分配给 ${to}`);
|
save(data);
|
json(res, 200, { customer });
|
return;
|
}
|
|
json(res, 404, { error: "API not found" });
|
}
|
|
function serveFile(req, res) {
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
let pathname = decodeURIComponent(url.pathname);
|
if (pathname === "/") pathname = "/sales_crm_full_demo.html";
|
const file = path.resolve(root, `.${pathname}`);
|
if (!file.startsWith(root)) {
|
res.writeHead(403);
|
res.end("Forbidden");
|
return;
|
}
|
fs.readFile(file, (err, data) => {
|
if (err) {
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
res.end("Not found");
|
return;
|
}
|
const ext = path.extname(file).toLowerCase();
|
const type = ext === ".html" ? "text/html; charset=utf-8" : "application/octet-stream";
|
res.writeHead(200, { "Content-Type": type });
|
res.end(data);
|
});
|
}
|
|
function createServer(options = {}) {
|
return http.createServer((req, res) => {
|
if (req.url.startsWith("/api/")) {
|
handleApi(req, res, options).catch((err) => json(res, err.status || 500, { error: err.message }));
|
} else {
|
serveFile(req, res);
|
}
|
});
|
}
|
|
if (require.main === module) {
|
ensureData();
|
const server = createServer();
|
server.listen(port, "0.0.0.0", () => {
|
console.log(`Full CRM demo is running at http://127.0.0.1:${port}/`);
|
});
|
}
|
|
module.exports = {
|
createServer,
|
dataVersion,
|
makeToken,
|
mutateAccountWithCas,
|
validatePasswordChangePayload
|
};
|