城市合伙人端的修改(后台)
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
resolveOrderCityPartner,
|
||||
validatePartnerCityBinding,
|
||||
validatePartnerCommissionRates,
|
||||
} from './city-partner';
|
||||
|
||||
describe('resolveOrderCityPartner', () => {
|
||||
const bindings = [
|
||||
{
|
||||
id: '1',
|
||||
partnerAccountId: '10',
|
||||
scopeType: 'CITY_WIDE' as const,
|
||||
districtCodes: null,
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 0.03,
|
||||
bindingStatus: 'ACTIVE' as const,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
partnerAccountId: '20',
|
||||
scopeType: 'DISTRICT' as const,
|
||||
districtCodes: ['410105', '金水区'],
|
||||
orderCommissionRate: 0.01,
|
||||
redeemCommissionRate: 0.03,
|
||||
bindingStatus: 'ACTIVE' as const,
|
||||
},
|
||||
];
|
||||
|
||||
it('prefers district partner when district matches adcode', () => {
|
||||
const ref = resolveOrderCityPartner(bindings, '410105');
|
||||
expect(ref?.partnerAccountId).toBe('20');
|
||||
expect(ref?.scopeType).toBe('DISTRICT');
|
||||
});
|
||||
|
||||
it('falls back to city-wide partner', () => {
|
||||
const ref = resolveOrderCityPartner(bindings, '410102');
|
||||
expect(ref?.partnerAccountId).toBe('10');
|
||||
expect(ref?.scopeType).toBe('CITY_WIDE');
|
||||
});
|
||||
|
||||
it('returns null when no bindings', () => {
|
||||
expect(resolveOrderCityPartner([], '410105')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePartnerCityBinding', () => {
|
||||
it('rejects duplicate partner', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[{ id: '1', partnerAccountId: '10', scopeType: 'CITY_WIDE' }],
|
||||
{ partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects second city-wide partner', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[{ id: '1', partnerAccountId: '10', scopeType: 'CITY_WIDE' }],
|
||||
{ partnerAccountId: '11', scopeType: 'CITY_WIDE' },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects overlapping district codes', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }],
|
||||
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: ['410105'] },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('allows valid district binding', () => {
|
||||
const result = validatePartnerCityBinding(
|
||||
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }],
|
||||
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: ['410106'] },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePartnerCommissionRates', () => {
|
||||
it('allows sum within default 5% cap', () => {
|
||||
expect(validatePartnerCommissionRates(0.02, 0.03).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects sum above cap', () => {
|
||||
const result = validatePartnerCommissionRates(0.03, 0.03, 0.05);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toContain('5.00%');
|
||||
});
|
||||
|
||||
it('respects custom city cap', () => {
|
||||
expect(validatePartnerCommissionRates(0.04, 0.04, 0.08).ok).toBe(true);
|
||||
expect(validatePartnerCommissionRates(0.05, 0.04, 0.08).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
export type CityPartnerScopeType = 'CITY_WIDE' | 'DISTRICT';
|
||||
export type CityPartnerStatus = 'ACTIVE' | 'PAUSED';
|
||||
|
||||
export interface PartnerCityBindingInput {
|
||||
id?: string;
|
||||
partnerAccountId: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: CityPartnerStatus;
|
||||
}
|
||||
|
||||
export interface PartnerCityResolveRef {
|
||||
id: string;
|
||||
partnerAccountId: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
orderCommissionRate: number;
|
||||
redeemCommissionRate: number;
|
||||
}
|
||||
|
||||
export interface PartnerCityValidationResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function normalizeDistrictCodes(codes?: string[] | null): string[] {
|
||||
if (!codes?.length) return [];
|
||||
return [...new Set(codes.map((c) => String(c).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
/** 区县 adcode 或名称命中区域合伙;否则全城合伙 */
|
||||
export function resolveOrderCityPartner(
|
||||
bindings: Array<{
|
||||
id: string;
|
||||
partnerAccountId: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate: number;
|
||||
redeemCommissionRate: number;
|
||||
bindingStatus?: CityPartnerStatus;
|
||||
}>,
|
||||
receiverDistrict?: string | null,
|
||||
): PartnerCityResolveRef | null {
|
||||
const active = bindings.filter((b) => b.bindingStatus !== 'PAUSED');
|
||||
if (!active.length) return null;
|
||||
|
||||
const districtKey = receiverDistrict?.trim();
|
||||
if (districtKey) {
|
||||
const districtHit = active.find((b) => {
|
||||
if (b.scopeType !== 'DISTRICT') return false;
|
||||
const codes = normalizeDistrictCodes(b.districtCodes);
|
||||
return codes.some((code) => code === districtKey || districtKey.includes(code) || code.includes(districtKey));
|
||||
});
|
||||
if (districtHit) {
|
||||
return {
|
||||
id: districtHit.id,
|
||||
partnerAccountId: districtHit.partnerAccountId,
|
||||
scopeType: districtHit.scopeType,
|
||||
orderCommissionRate: districtHit.orderCommissionRate,
|
||||
redeemCommissionRate: districtHit.redeemCommissionRate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cityWide = active.find((b) => b.scopeType === 'CITY_WIDE');
|
||||
if (cityWide) {
|
||||
return {
|
||||
id: cityWide.id,
|
||||
partnerAccountId: cityWide.partnerAccountId,
|
||||
scopeType: cityWide.scopeType,
|
||||
orderCommissionRate: cityWide.orderCommissionRate,
|
||||
redeemCommissionRate: cityWide.redeemCommissionRate,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validatePartnerCityBinding(
|
||||
existing: PartnerCityBindingInput[],
|
||||
input: PartnerCityBindingInput,
|
||||
excludeId?: string,
|
||||
): PartnerCityValidationResult {
|
||||
const others = existing.filter((b) => b.id !== excludeId);
|
||||
|
||||
if (others.some((b) => b.partnerAccountId === input.partnerAccountId)) {
|
||||
return { ok: false, message: '该合伙人已绑定此城市' };
|
||||
}
|
||||
|
||||
if (input.scopeType === 'CITY_WIDE') {
|
||||
if (others.some((b) => b.scopeType === 'CITY_WIDE')) {
|
||||
return { ok: false, message: '每城最多 1 名全城合伙人' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const districts = normalizeDistrictCodes(input.districtCodes);
|
||||
if (!districts.length) {
|
||||
return { ok: false, message: '区域合伙人须至少选择一个区县' };
|
||||
}
|
||||
|
||||
const occupied = new Set<string>();
|
||||
for (const row of others) {
|
||||
if (row.scopeType !== 'DISTRICT') continue;
|
||||
for (const code of normalizeDistrictCodes(row.districtCodes)) {
|
||||
occupied.add(code);
|
||||
}
|
||||
}
|
||||
|
||||
for (const code of districts) {
|
||||
if (occupied.has(code)) {
|
||||
return { ok: false, message: `区县 ${code} 已被其他区域合伙人占用` };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** @deprecated use validatePartnerCityBinding */
|
||||
export const validateCityPartnerBinding = validatePartnerCityBinding;
|
||||
|
||||
export const DEFAULT_MAX_PARTNER_COMMISSION_RATE = 0.05;
|
||||
|
||||
export function resolveMaxPartnerCommissionRate(maxRate?: number | null): number {
|
||||
if (maxRate == null || Number.isNaN(Number(maxRate))) {
|
||||
return DEFAULT_MAX_PARTNER_COMMISSION_RATE;
|
||||
}
|
||||
return Number(maxRate);
|
||||
}
|
||||
|
||||
/** 订单佣金 + 核销佣金不得超过城市配置上限(默认 5%) */
|
||||
export function validatePartnerCommissionRates(
|
||||
orderCommissionRate: number,
|
||||
redeemCommissionRate: number,
|
||||
maxSumRate = DEFAULT_MAX_PARTNER_COMMISSION_RATE,
|
||||
): PartnerCityValidationResult {
|
||||
const sum = orderCommissionRate + redeemCommissionRate;
|
||||
const max = resolveMaxPartnerCommissionRate(maxSumRate);
|
||||
if (sum > max + 1e-9) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -125,3 +125,5 @@ export function orderTabToStatuses(tab: string): string[] | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export * from './city-partner';
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface CityDto {
|
||||
name: string;
|
||||
province: string;
|
||||
status: string;
|
||||
/** 订单+核销佣金合计上限(小数,默认 0.05) */
|
||||
maxPartnerCommissionRate?: number;
|
||||
}
|
||||
|
||||
export interface ProductDto {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { CityPartnerScopeType, CityPartnerStatus } from './enums';
|
||||
|
||||
/** @deprecated use PartnerPrimaryAccountDto */
|
||||
export type CityPartnerDto = PartnerPrimaryAccountDto;
|
||||
|
||||
export interface PartnerPrimaryAccountDto {
|
||||
id: string;
|
||||
cityId: string;
|
||||
cityName?: string | null;
|
||||
cityCode?: string | null;
|
||||
companyName: string;
|
||||
contactPhone?: string | null;
|
||||
address?: string | null;
|
||||
phone: string;
|
||||
name: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
districtCodes: string[] | null;
|
||||
orderCommissionRate: number;
|
||||
redeemCommissionRate: number;
|
||||
bindingStatus: CityPartnerStatus;
|
||||
managedWarehouseId?: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreatePartnerPrimaryInput {
|
||||
cityId: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
companyName: string;
|
||||
address: string;
|
||||
contactPhone?: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
districtCodes?: string[];
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
bindingStatus?: CityPartnerStatus;
|
||||
managedWarehouseId?: string;
|
||||
contractNo?: string;
|
||||
bankAccountName?: string;
|
||||
bankAccountNo?: string;
|
||||
bankBranch?: string;
|
||||
weeklyStoreTarget?: number;
|
||||
}
|
||||
|
||||
export interface UpdatePartnerPrimaryInput {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
companyName?: string;
|
||||
address?: string;
|
||||
contactPhone?: string;
|
||||
scopeType?: CityPartnerScopeType;
|
||||
districtCodes?: string[];
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
bindingStatus?: CityPartnerStatus;
|
||||
managedWarehouseId?: string | null;
|
||||
contractNo?: string;
|
||||
bankAccountName?: string;
|
||||
bankAccountNo?: string;
|
||||
bankBranch?: string;
|
||||
weeklyStoreTarget?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface PartnerCityBindingRef {
|
||||
id: string;
|
||||
partnerAccountId: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
districtCodes: string[] | null;
|
||||
orderCommissionRate: number;
|
||||
redeemCommissionRate: number;
|
||||
bindingStatus: CityPartnerStatus;
|
||||
}
|
||||
|
||||
export const PARTNER_PERMISSION_KEYS = [
|
||||
'warehouse:manage',
|
||||
'store:manage',
|
||||
'store:create',
|
||||
'order:view',
|
||||
] as const;
|
||||
|
||||
export type PartnerPermissionKey = (typeof PARTNER_PERMISSION_KEYS)[number];
|
||||
|
||||
export const PARTNER_PERMISSION_LABELS: Record<PartnerPermissionKey, string> = {
|
||||
'warehouse:manage': '仓库管理',
|
||||
'store:manage': '门店管理',
|
||||
'store:create': '开店管理',
|
||||
'order:view': '订单查看',
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { WarehouseManagerType, WarehouseStatus } from './enums';
|
||||
|
||||
export interface CityWarehouseDto {
|
||||
id: string;
|
||||
cityId: string;
|
||||
name: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
managerType: WarehouseManagerType;
|
||||
partnerAccountId: string | null;
|
||||
partnerCompanyName?: string | null;
|
||||
status: WarehouseStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateCityWarehouseInput {
|
||||
name: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
managerType: WarehouseManagerType;
|
||||
partnerAccountId?: string;
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
|
||||
export interface UpdateCityWarehouseInput {
|
||||
name?: string;
|
||||
address?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
managerType?: WarehouseManagerType;
|
||||
partnerAccountId?: string | null;
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
@@ -72,6 +72,46 @@ export enum PartnerStaffRole {
|
||||
PROMOTER = 'PROMOTER',
|
||||
}
|
||||
|
||||
export enum CityPartnerScopeType {
|
||||
CITY_WIDE = 'CITY_WIDE',
|
||||
DISTRICT = 'DISTRICT',
|
||||
}
|
||||
|
||||
export enum CityPartnerStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
PAUSED = 'PAUSED',
|
||||
}
|
||||
|
||||
export enum WarehouseManagerType {
|
||||
HQ = 'HQ',
|
||||
PARTNER = 'PARTNER',
|
||||
}
|
||||
|
||||
export enum WarehouseStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
PAUSED = 'PAUSED',
|
||||
}
|
||||
|
||||
export const CITY_PARTNER_SCOPE_LABELS: Record<CityPartnerScopeType, string> = {
|
||||
[CityPartnerScopeType.CITY_WIDE]: '全城合伙人',
|
||||
[CityPartnerScopeType.DISTRICT]: '区域合伙人',
|
||||
};
|
||||
|
||||
export const WAREHOUSE_MANAGER_LABELS: Record<WarehouseManagerType, string> = {
|
||||
[WarehouseManagerType.HQ]: '总部直管',
|
||||
[WarehouseManagerType.PARTNER]: '合伙人管仓',
|
||||
};
|
||||
|
||||
export const CITY_PARTNER_STATUS_LABELS: Record<CityPartnerStatus, string> = {
|
||||
[CityPartnerStatus.ACTIVE]: '启用',
|
||||
[CityPartnerStatus.PAUSED]: '暂停',
|
||||
};
|
||||
|
||||
export const WAREHOUSE_STATUS_LABELS: Record<WarehouseStatus, string> = {
|
||||
[WarehouseStatus.ACTIVE]: '启用',
|
||||
[WarehouseStatus.PAUSED]: '暂停',
|
||||
};
|
||||
|
||||
export enum AccountStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
DISABLED = 'DISABLED',
|
||||
|
||||
@@ -15,3 +15,5 @@ export * from './partner-log';
|
||||
export * from './promo';
|
||||
export * from './hq-permissions';
|
||||
export * from './partner';
|
||||
export * from './city-partner';
|
||||
export * from './city-warehouse';
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
export type PartnerLogCategory =
|
||||
| 'login'
|
||||
| 'wechat_auth'
|
||||
| 'account_ops'
|
||||
| 'store_ops'
|
||||
| 'shipping'
|
||||
| 'settlement';
|
||||
| 'settlement'
|
||||
| 'warehouse_ops';
|
||||
|
||||
export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly string[]> = {
|
||||
login: ['partner_sms_send', 'partner_sms_login', 'partner_sms_verify_fail', 'partner_login_success'],
|
||||
wechat_auth: ['partner_wechat_login', 'partner_wechat_bind'],
|
||||
account_ops: [
|
||||
'partner_staff_create',
|
||||
'partner_staff_update',
|
||||
'partner_staff_delete',
|
||||
'partner_staff_permission_update',
|
||||
],
|
||||
store_ops: ['partner_store_create', 'partner_store_status_change'],
|
||||
shipping: ['partner_order_ship', 'partner_delivery_advance'],
|
||||
settlement: ['partner_bill_view', 'partner_bill_detail_view'],
|
||||
warehouse_ops: ['partner_warehouse_view', 'partner_warehouse_update'],
|
||||
};
|
||||
|
||||
export const PARTNER_LOG_CATEGORY_OPTIONS: Array<{ value: PartnerLogCategory | ''; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'login', label: '登录' },
|
||||
{ value: 'wechat_auth', label: '微信授权' },
|
||||
{ value: 'account_ops', label: '子账号管理' },
|
||||
{ value: 'store_ops', label: '门店操作' },
|
||||
{ value: 'shipping', label: '发货/配送' },
|
||||
{ value: 'settlement', label: '结算' },
|
||||
{ value: 'warehouse_ops', label: '仓库操作' },
|
||||
];
|
||||
|
||||
export const PARTNER_LOG_CATEGORY_LABELS: Record<PartnerLogCategory | '', string> = {
|
||||
'': '全部',
|
||||
login: '登录',
|
||||
wechat_auth: '微信授权',
|
||||
account_ops: '子账号管理',
|
||||
store_ops: '门店操作',
|
||||
shipping: '发货/配送',
|
||||
settlement: '结算',
|
||||
warehouse_ops: '仓库操作',
|
||||
};
|
||||
|
||||
export function resolvePartnerLogCategory(eventName: string): PartnerLogCategory | null {
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface PartnerMe {
|
||||
companyName?: string;
|
||||
hasWechat?: boolean;
|
||||
staffRole?: PartnerStaffRole;
|
||||
permissions?: string[];
|
||||
primaryAccountId?: string;
|
||||
}
|
||||
|
||||
export interface PartnerStaffItem {
|
||||
@@ -15,6 +17,7 @@ export interface PartnerStaffItem {
|
||||
name: string;
|
||||
phone: string;
|
||||
staffRole: PartnerStaffRole;
|
||||
permissions?: string[];
|
||||
status: AccountStatus;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
@@ -22,11 +25,13 @@ export interface PartnerStaffItem {
|
||||
export interface CreatePartnerStaffRequest {
|
||||
phone: string;
|
||||
name: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface UpdatePartnerStaffRequest {
|
||||
name?: string;
|
||||
staffRole?: PartnerStaffRole;
|
||||
permissions?: string[];
|
||||
status?: AccountStatus;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user