城市合伙人端的修改(后台)

This commit is contained in:
2026-07-12 10:19:39 +08:00
parent d0f0fa09af
commit 7f031cc4c2
74 changed files with 5430 additions and 975 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
# 服务器:仅使用 .env.productionbash deploy/sync-api-env.sh production
# .env.development — 本地参考模板,不部署到服务器
DATABASE_URL="mysql://root:root@localhost:3306/dukang_haoke"
REDIS_URL="redis://localhost:6379"
DATABASE_URL="mysql://root:root@localhost:6016/dukang_haoke"
REDIS_URL="redis://localhost:6017"
JWT_SECRET="dukang-prev1-dev-secret-change-in-prod"
JWT_EXPIRES_IN="7d"
PORT=3000
+1
View File
@@ -11,6 +11,7 @@
"prisma:migrate": "prisma migrate dev",
"prisma:validate": "prisma validate",
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
"prisma:migrate-city-partner": "ts-node --transpile-only prisma/migrate-city-partner.ts",
"prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
+29
View File
@@ -0,0 +1,29 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
/** 清空开城城市及关联数据(保留 HQ 账户、商品目录等) */
async function main() {
await prisma.logPartnerAnalytics.deleteMany();
await prisma.redeemRecord.deleteMany();
await prisma.benefitCoupon.deleteMany();
await prisma.orderDelivery.deleteMany();
await prisma.order.deleteMany();
await prisma.commonEvent.deleteMany({ where: { refType: { in: ['CITY', 'WAREHOUSE', 'PARTNER', 'PARTNER_ACCOUNT', 'STORE'] } } });
await prisma.storeAccount.deleteMany();
await prisma.store.deleteMany();
await prisma.partnerBill.deleteMany();
await prisma.cityWarehouse.deleteMany();
await prisma.partnerAccount.deleteMany();
const result = await prisma.commonCity.deleteMany();
console.log(`Cleared ${result.count} cities and related open-city data.`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+84 -71
View File
@@ -148,25 +148,7 @@ CREATE TABLE common_promo_code (
UNIQUE KEY uk_common_promo_code_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广码';
-- ===================== PARTNER先于 city/store =============================
DROP TABLE IF EXISTS partner_partner;
CREATE TABLE partner_partner (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
company_name VARCHAR(128) NOT NULL,
address VARCHAR(256) NOT NULL,
contact_phone VARCHAR(20) NOT NULL,
contract_no VARCHAR(64) DEFAULT NULL COMMENT '合同编号',
contract_signed_at DATETIME(3) DEFAULT NULL,
contract_expire_at DATETIME(3) DEFAULT NULL,
bank_account_name VARCHAR(64) DEFAULT NULL,
bank_account_no VARCHAR(32) DEFAULT NULL,
bank_branch VARCHAR(128) DEFAULT 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),
PRIMARY KEY (id),
KEY idx_partner_partner_phone (contact_phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市合伙人主体';
-- ===================== PARTNER城市合伙人主账号 + 子账号 =============================
DROP TABLE IF EXISTS common_city;
CREATE TABLE common_city (
@@ -175,71 +157,99 @@ CREATE TABLE common_city (
name VARCHAR(64) NOT NULL,
province VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
partner_id BIGINT UNSIGNED DEFAULT NULL,
local_min_qty INT NOT NULL DEFAULT 2,
cross_min_qty INT NOT NULL DEFAULT 6,
max_partner_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0500 COMMENT '订单+核销佣金合计上限',
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),
PRIMARY KEY (id),
UNIQUE KEY uk_common_city_code (code),
KEY idx_common_city_partner (partner_id),
CONSTRAINT fk_common_city_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE SET NULL
UNIQUE KEY uk_common_city_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='开城配置';
DROP TABLE IF EXISTS common_city_commission_rule;
CREATE TABLE common_city_commission_rule (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
city_id BIGINT UNSIGNED NOT NULL,
order_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
redeem_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
partner_profit_rate DECIMAL(5,4) NOT NULL DEFAULT 0.3500,
store_settlement_rate DECIMAL(5,4) NOT NULL DEFAULT 0.6000,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_common_city_commission_city (city_id),
CONSTRAINT fk_common_city_commission_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市佣金规则';
DROP TABLE IF EXISTS partner_account;
CREATE TABLE partner_account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
partner_id BIGINT UNSIGNED NOT NULL,
phone VARCHAR(20) NOT NULL,
name VARCHAR(64) NOT NULL,
wx_open_id VARCHAR(64) DEFAULT NULL,
wx_union_id VARCHAR(64) DEFAULT NULL,
is_primary TINYINT NOT NULL DEFAULT 0,
parent_account_id BIGINT UNSIGNED DEFAULT NULL,
staff_role VARCHAR(16) DEFAULT NULL COMMENT 'PARTNER|INTERNAL|PROMOTER',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
last_login_at DATETIME(3) DEFAULT 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),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
phone VARCHAR(20) NOT NULL,
name VARCHAR(64) NOT NULL,
wx_open_id VARCHAR(64) DEFAULT NULL,
wx_union_id VARCHAR(64) DEFAULT NULL,
is_primary TINYINT NOT NULL DEFAULT 0,
parent_account_id BIGINT UNSIGNED DEFAULT NULL,
staff_role VARCHAR(16) DEFAULT NULL COMMENT 'PARTNER|INTERNAL|PROMOTER',
permissions JSON DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
last_login_at DATETIME(3) DEFAULT NULL,
city_id BIGINT UNSIGNED DEFAULT NULL COMMENT '主账号绑定城市',
scope_type VARCHAR(16) DEFAULT NULL COMMENT 'CITY_WIDE|DISTRICT',
district_codes JSON DEFAULT NULL,
order_commission_rate DECIMAL(5,4) DEFAULT 0.0000,
redeem_commission_rate DECIMAL(5,4) DEFAULT 0.0300,
binding_status VARCHAR(16) DEFAULT 'ACTIVE',
company_name VARCHAR(128) DEFAULT NULL,
address VARCHAR(256) DEFAULT NULL,
contact_phone VARCHAR(20) DEFAULT NULL,
contract_no VARCHAR(64) DEFAULT NULL,
contract_signed_at DATETIME(3) DEFAULT NULL,
contract_expire_at DATETIME(3) DEFAULT NULL,
bank_account_name VARCHAR(64) DEFAULT NULL,
bank_account_no VARCHAR(32) DEFAULT NULL,
bank_branch VARCHAR(128) DEFAULT NULL,
weekly_store_target INT DEFAULT 20,
managed_warehouse_id BIGINT UNSIGNED DEFAULT 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),
PRIMARY KEY (id),
UNIQUE KEY uk_partner_account_phone (phone),
KEY idx_partner_account_partner (partner_id),
CONSTRAINT fk_partner_account_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE RESTRICT,
UNIQUE KEY uk_partner_account_managed_warehouse (managed_warehouse_id),
KEY idx_partner_account_city_scope (city_id, scope_type),
KEY idx_partner_account_city_primary (city_id, is_primary),
KEY idx_partner_account_parent (parent_account_id),
KEY idx_partner_account_contact_phone (contact_phone),
CONSTRAINT fk_partner_account_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE RESTRICT,
CONSTRAINT fk_partner_account_parent FOREIGN KEY (parent_account_id) REFERENCES partner_account(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合伙人账号';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市合伙人账号(主账号=实体+城市绑定)';
DROP TABLE IF EXISTS common_city_warehouse;
CREATE TABLE common_city_warehouse (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
city_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(128) NOT NULL,
address VARCHAR(256) NOT NULL,
contact_name VARCHAR(64) NOT NULL,
contact_phone VARCHAR(20) NOT NULL,
manager_type VARCHAR(16) NOT NULL COMMENT 'HQ|PARTNER',
partner_account_id BIGINT UNSIGNED DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
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),
PRIMARY KEY (id),
KEY idx_city_warehouse_city (city_id),
KEY idx_city_warehouse_partner_account (partner_account_id),
CONSTRAINT fk_city_warehouse_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE CASCADE,
CONSTRAINT fk_city_warehouse_partner_account FOREIGN KEY (partner_account_id) REFERENCES partner_account(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市仓库';
ALTER TABLE partner_account
ADD CONSTRAINT fk_partner_account_managed_warehouse FOREIGN KEY (managed_warehouse_id) REFERENCES common_city_warehouse(id) ON DELETE SET NULL;
DROP TABLE IF EXISTS partner_bill;
CREATE TABLE partner_bill (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
bill_no VARCHAR(32) NOT NULL,
partner_id BIGINT UNSIGNED NOT NULL,
period_start DATETIME(3) NOT NULL,
period_end DATETIME(3) NOT NULL,
order_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '下单佣金汇总',
redeem_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '核销佣金汇总',
total_amount DECIMAL(10,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
confirmed_at DATETIME(3) DEFAULT NULL,
paid_at DATETIME(3) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
bill_no VARCHAR(32) NOT NULL,
partner_account_id BIGINT UNSIGNED NOT NULL,
period_start DATETIME(3) NOT NULL,
period_end DATETIME(3) NOT NULL,
order_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '下单佣金汇总',
redeem_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '核销佣金汇总',
total_amount DECIMAL(10,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
confirmed_at DATETIME(3) DEFAULT NULL,
paid_at DATETIME(3) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_partner_bill_no (bill_no),
KEY idx_partner_bill_partner_status (partner_id, status),
CONSTRAINT fk_partner_bill_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE RESTRICT
KEY idx_partner_bill_account_status (partner_account_id, status),
CONSTRAINT fk_partner_bill_account FOREIGN KEY (partner_account_id) REFERENCES partner_account(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合伙人T+30账单';
-- ===================== HQ =============================
@@ -345,7 +355,7 @@ DROP TABLE IF EXISTS store_store;
CREATE TABLE store_store (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
city_id BIGINT UNSIGNED NOT NULL,
partner_id BIGINT UNSIGNED NOT NULL,
partner_account_id BIGINT UNSIGNED NOT NULL,
category_id BIGINT UNSIGNED DEFAULT NULL,
name VARCHAR(128) NOT NULL,
phone VARCHAR(20) NOT NULL,
@@ -366,13 +376,14 @@ CREATE TABLE store_store (
bank_account_name VARCHAR(64) DEFAULT NULL,
bank_account_no VARCHAR(32) DEFAULT NULL,
bank_branch VARCHAR(128) DEFAULT NULL,
settlement_rate DECIMAL(5,4) NOT NULL DEFAULT 0.6000 COMMENT '门店核销结算比例',
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),
PRIMARY KEY (id),
KEY idx_store_store_city_status (city_id, status),
KEY idx_store_store_partner (partner_id),
KEY idx_store_store_partner_account (partner_account_id),
CONSTRAINT fk_store_store_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE RESTRICT,
CONSTRAINT fk_store_store_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE RESTRICT,
CONSTRAINT fk_store_store_partner_account FOREIGN KEY (partner_account_id) REFERENCES partner_account(id) ON DELETE RESTRICT,
CONSTRAINT fk_store_store_category FOREIGN KEY (category_id) REFERENCES common_store_category(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='餐饮门店';
@@ -446,7 +457,9 @@ CREATE TABLE user_order (
shipped_at DATETIME(3) DEFAULT NULL COMMENT '发货时间(冗余=user_order_delivery.shipping_at)',
completed_at DATETIME(3) DEFAULT NULL COMMENT '完成时间',
cancelled_at DATETIME(3) DEFAULT NULL COMMENT '取消时间',
pay_expire_at DATETIME(3) DEFAULT NULL COMMENT '待付款过期时间',
pay_expire_at DATETIME(3) DEFAULT NULL COMMENT '待付款过期时间',
partner_account_id_at_pay BIGINT UNSIGNED DEFAULT NULL,
order_commission_rate_at_pay DECIMAL(5,4) DEFAULT NULL,
remark VARCHAR(512) DEFAULT 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),
@@ -0,0 +1,64 @@
/**
* 将 legacy common_city.partner_id 迁移至 common_city_partnerCITY_WIDE)。
* 在 prisma db push 移除 partner_id 列之前运行;若列已不存在则跳过数据拷贝。
*
* 用法:cd server/dukang-api && pnpm prisma:migrate-city-partner
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function columnExists(table: string, column: string): Promise<boolean> {
const rows = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) AS cnt FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
table,
column,
);
return Number(rows[0]?.cnt ?? 0) > 0;
}
async function main() {
const hasPartnerId = await columnExists('common_city', 'partner_id');
if (!hasPartnerId) {
console.log('[migrate-city-partner] common_city.partner_id 已移除,跳过迁移');
return;
}
const cities = await prisma.$queryRawUnsafe<
Array<{ id: bigint; partner_id: bigint | null }>
>(`SELECT id, partner_id FROM common_city WHERE partner_id IS NOT NULL`);
let migrated = 0;
for (const row of cities) {
const cityId = row.id;
const partnerId = row.partner_id!;
const existing = await prisma.cityPartner.findFirst({
where: { cityId, partnerId },
});
if (existing) continue;
const rule = await prisma.commonCityCommissionRule.findUnique({ where: { cityId } });
await prisma.cityPartner.create({
data: {
cityId,
partnerId,
scopeType: 'CITY_WIDE',
orderCommissionRate: rule ? Number((rule as { orderCommissionRate?: unknown }).orderCommissionRate ?? 0) : 0,
redeemCommissionRate: rule ? Number((rule as { redeemCommissionRate?: unknown }).redeemCommissionRate ?? 0.03) : 0.03,
status: 'ACTIVE',
},
});
migrated += 1;
}
console.log(`[migrate-city-partner] 已迁移 ${migrated} 条 CITY_WIDE 绑定`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,32 @@
/**
* Partner architecture unification migration stub.
*
* Schema change: Partner / CityPartner / CommonCityCommissionRule removed;
* PartnerAccount is the primary entity; Store.partnerAccountId;
* Order.partnerAccountIdAtPay.
*
* This is a breaking schema change — there is no incremental SQL migration path
* from the legacy v3.1 tables. Apply on a fresh or disposable database:
*
* cd server/dukang-api
* npx prisma db push --force-reset
* pnpm prisma:seed
*
* Do NOT run --force-reset against production.
*/
async function main() {
console.log(`
Partner account unification requires a full schema reset.
Run:
cd server/dukang-api
npx prisma db push --force-reset
pnpm prisma:seed
`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+108 -81
View File
@@ -106,6 +106,26 @@ enum CityStatus {
PAUSED
}
enum CityPartnerScopeType {
CITY_WIDE
DISTRICT
}
enum CityPartnerStatus {
ACTIVE
PAUSED
}
enum WarehouseManagerType {
HQ
PARTNER
}
enum WarehouseStatus {
ACTIVE
PAUSED
}
enum PartnerStaffRole {
PARTNER
INTERNAL
@@ -391,103 +411,109 @@ model CommonCity {
name String @db.VarChar(64)
province String @db.VarChar(32)
status CityStatus @default(PENDING)
partnerId BigInt? @map("partner_id") @db.UnsignedBigInt
localMinQty Int @default(2) @map("local_min_qty")
crossMinQty Int @default(6) @map("cross_min_qty")
maxPartnerCommissionRate Decimal @default(0.05) @map("max_partner_commission_rate") @db.Decimal(5, 4)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
partner Partner? @relation(fields: [partnerId], references: [id], onDelete: SetNull)
commissionRule CommonCityCommissionRule?
stores Store[]
orders Order[]
warehouses CityWarehouse[]
partnerAccounts PartnerAccount[] @relation("PartnerAccountCity")
stores Store[]
orders Order[]
@@index([partnerId])
@@map("common_city")
}
model CommonCityCommissionRule {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @unique @map("city_id") @db.UnsignedBigInt
orderCommissionRate Decimal @default(0) @map("order_commission_rate") @db.Decimal(5, 4)
redeemCommissionRate Decimal @default(0) @map("redeem_commission_rate") @db.Decimal(5, 4)
partnerProfitRate Decimal @default(0.35) @map("partner_profit_rate") @db.Decimal(5, 4)
storeSettlementRate Decimal @default(0.60) @map("store_settlement_rate") @db.Decimal(5, 4)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
model CityWarehouse {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt
name String @db.VarChar(128)
address String @db.VarChar(256)
contactName String @map("contact_name") @db.VarChar(64)
contactPhone String @map("contact_phone") @db.VarChar(20)
managerType WarehouseManagerType @map("manager_type")
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
status WarehouseStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
partnerAccount PartnerAccount? @relation("WarehouseManager", fields: [partnerAccountId], references: [id], onDelete: SetNull)
managedBy PartnerAccount? @relation("ManagedWarehouse")
@@map("common_city_commission_rule")
@@index([cityId])
@@index([partnerAccountId])
@@map("common_city_warehouse")
}
// ─── PARTNER ──────────────────────────────────────────
model Partner {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
companyName String @map("company_name") @db.VarChar(128)
address String @db.VarChar(256)
contactPhone String @map("contact_phone") @db.VarChar(20)
contractNo String? @map("contract_no") @db.VarChar(64)
contractSignedAt DateTime? @map("contract_signed_at") @db.DateTime(3)
contractExpireAt DateTime? @map("contract_expire_at") @db.DateTime(3)
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
bankBranch String? @map("bank_branch") @db.VarChar(128)
weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
cities CommonCity[]
accounts PartnerAccount[]
stores Store[]
bills PartnerBill[]
@@index([contactPhone])
@@map("partner_partner")
}
model PartnerAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
name String @db.VarChar(64)
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
isPrimary Int @default(0) @map("is_primary") @db.TinyInt
parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
staffRole PartnerStaffRole? @map("staff_role")
status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
name String @db.VarChar(64)
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
isPrimary Int @default(0) @map("is_primary") @db.TinyInt
parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
staffRole PartnerStaffRole? @map("staff_role")
permissions Json?
status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
cityId BigInt? @map("city_id") @db.UnsignedBigInt
scopeType CityPartnerScopeType? @map("scope_type")
districtCodes Json? @map("district_codes")
orderCommissionRate Decimal? @default(0) @map("order_commission_rate") @db.Decimal(5, 4)
redeemCommissionRate Decimal? @default(0.03) @map("redeem_commission_rate") @db.Decimal(5, 4)
bindingStatus CityPartnerStatus? @default(ACTIVE) @map("binding_status")
companyName String? @map("company_name") @db.VarChar(128)
address String? @db.VarChar(256)
contactPhone String? @map("contact_phone") @db.VarChar(20)
contractNo String? @map("contract_no") @db.VarChar(64)
contractSignedAt DateTime? @map("contract_signed_at") @db.DateTime(3)
contractExpireAt DateTime? @map("contract_expire_at") @db.DateTime(3)
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
bankBranch String? @map("bank_branch") @db.VarChar(128)
weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
managedWarehouseId BigInt? @unique @map("managed_warehouse_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
parent PartnerAccount? @relation("PartnerAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: SetNull)
children PartnerAccount[] @relation("PartnerAccountHierarchy")
city CommonCity? @relation("PartnerAccountCity", fields: [cityId], references: [id], onDelete: Restrict)
managedWarehouse CityWarehouse? @relation("ManagedWarehouse", fields: [managedWarehouseId], references: [id], onDelete: SetNull)
managedWarehouses CityWarehouse[] @relation("WarehouseManager")
parent PartnerAccount? @relation("PartnerAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: SetNull)
children PartnerAccount[] @relation("PartnerAccountHierarchy")
stores Store[]
bills PartnerBill[]
@@index([partnerId])
@@index([cityId, scopeType])
@@index([cityId, isPrimary])
@@index([parentAccountId])
@@index([wxOpenId])
@@index([contactPhone])
@@map("partner_account")
}
model PartnerBill {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
billNo String @unique @map("bill_no") @db.VarChar(32)
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
periodStart DateTime @map("period_start") @db.DateTime(3)
periodEnd DateTime @map("period_end") @db.DateTime(3)
orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2)
redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
status PartnerBillStatus @default(DRAFT)
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
billNo String @unique @map("bill_no") @db.VarChar(32)
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
periodStart DateTime @map("period_start") @db.DateTime(3)
periodEnd DateTime @map("period_end") @db.DateTime(3)
orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2)
redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
status PartnerBillStatus @default(DRAFT)
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
@@index([partnerId, status])
@@index([partnerAccountId, status])
@@map("partner_bill")
}
@@ -626,7 +652,7 @@ model UserPromoAttribution {
model Store {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
categoryId BigInt? @map("category_id") @db.UnsignedBigInt
name String @db.VarChar(128)
phone String @db.VarChar(20)
@@ -647,11 +673,12 @@ model Store {
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
bankBranch String? @map("bank_branch") @db.VarChar(128)
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
cityRef CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
account StoreAccount?
@@ -660,7 +687,7 @@ model Store {
payouts StorePayout[]
@@index([cityId, status])
@@index([partnerId])
@@index([partnerAccountId])
@@map("store_store")
}
@@ -730,10 +757,12 @@ model Order {
shippedAt DateTime? @map("shipped_at") @db.DateTime(3)
completedAt DateTime? @map("completed_at") @db.DateTime(3)
cancelledAt DateTime? @map("cancelled_at") @db.DateTime(3)
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt
orderCommissionRateAtPay Decimal? @map("order_commission_rate_at_pay") @db.Decimal(5, 4)
remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
@@ -915,8 +944,7 @@ model LogStoreAnalytics {
model LogPartnerAnalytics {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
eventName String @map("event_name") @db.VarChar(64)
clientApp ClientApp? @map("client_app")
refType String? @map("ref_type") @db.VarChar(32)
@@ -924,7 +952,6 @@ model LogPartnerAnalytics {
extraJson Json? @map("extra_json")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@index([partnerId, createdAt])
@@index([partnerAccountId, createdAt])
@@index([eventName, createdAt])
@@map("log_partner_analytics")
+502 -50
View File
@@ -1,421 +1,873 @@
import { PrismaClient, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
import { hashPassword } from '../src/common/crypto/password.util';
import { DEFAULT_PRODUCT_DETAIL_TEMPLATES } from './seeds/product-detail-templates.default';
const prisma = new PrismaClient();
async function createMockResource(
ownerType: ResourceOwnerType,
ownerId: bigint,
bizType: ResourceBizType,
url: string,
) {
return prisma.commonResource.create({
data: {
ownerType,
ownerId,
bizType,
mediaType: ResourceMediaType.IMAGE,
ossBucket: 'mock-dukang',
ossKey: `mock/${ownerType.toLowerCase()}/${ownerId}/${bizType.toLowerCase()}`,
url,
},
});
}
async function main() {
console.log('Seeding v3.1 data...');
await prisma.logUserAnalytics.deleteMany();
await prisma.logPartnerAnalytics.deleteMany();
await prisma.logThirdParty.deleteMany();
await prisma.storePayout.deleteMany();
await prisma.storeRating.deleteMany();
await prisma.redeemRecord.deleteMany();
await prisma.benefitCoupon.deleteMany();
await prisma.orderDelivery.deleteMany();
await prisma.order.deleteMany();
await prisma.commonEvent.deleteMany();
await prisma.commonTicket.deleteMany();
await prisma.userPromoAttribution.deleteMany();
await prisma.userCityPreference.deleteMany();
await prisma.userAddress.deleteMany();
await prisma.user.deleteMany();
await prisma.storeAccount.deleteMany();
await prisma.store.deleteMany();
await prisma.partnerBill.deleteMany();
await prisma.cityWarehouse.deleteMany();
await prisma.partnerAccount.deleteMany();
await prisma.commonCityCommissionRule.deleteMany();
await prisma.commonCity.deleteMany();
await prisma.partner.deleteMany();
await prisma.commonProductItem.deleteMany();
await prisma.commonProductDetailTemplate.deleteMany();
await prisma.commonStoreCategory.deleteMany();
await prisma.commonPromoCode.deleteMany();
await prisma.commonResource.deleteMany();
await prisma.hqAccount.deleteMany();
const partner = await prisma.partner.create({
data: {
companyName: '郑州城市合伙人',
address: '河南省郑州市金水区',
contactPhone: '13700000001',
bankAccountName: '郑州合伙人公司',
bankAccountNo: '6222021234567890',
bankBranch: '工商银行郑州分行',
weeklyStoreTarget: 20,
},
});
const city = await prisma.commonCity.create({
data: {
code: '410100',
name: '郑州市',
province: '河南省',
status: 'ACTIVE',
partnerId: partner.id,
localMinQty: 2,
crossMinQty: 6,
},
});
await prisma.commonCityCommissionRule.create({
const primaryAccount = await prisma.partnerAccount.create({
data: {
phone: '13700000001',
name: '郑州合伙人主账号',
isPrimary: 1,
staffRole: 'PARTNER',
status: 'ACTIVE',
cityId: city.id,
scopeType: 'CITY_WIDE',
orderCommissionRate: 0,
redeemCommissionRate: 0.03,
partnerProfitRate: 0.35,
storeSettlementRate: 0.6,
bindingStatus: 'ACTIVE',
companyName: '郑州城市合伙人',
address: '河南省郑州市金水区',
contactPhone: '13700000001',
bankAccountName: '郑州合伙人公司',
bankAccountNo: '6222021234567890',
bankBranch: '工商银行郑州分行',
weeklyStoreTarget: 20,
},
});
const warehouse = await prisma.cityWarehouse.create({
data: {
cityId: city.id,
name: '郑州中央仓',
address: '河南省郑州市金水区物流园1号',
contactName: '仓管张',
contactPhone: '13700000009',
managerType: 'PARTNER',
partnerAccountId: primaryAccount.id,
status: 'ACTIVE',
},
});
await prisma.partnerAccount.update({
where: { id: primaryAccount.id },
data: { managedWarehouseId: warehouse.id },
});
await prisma.partnerAccount.create({
data: {
phone: '13700000002',
name: '拓店员小李',
isPrimary: 0,
parentAccountId: primaryAccount.id,
staffRole: 'INTERNAL',
permissions: ['store:create', 'store:view'],
status: 'DISABLED',
},
});
const categories = await Promise.all([
prisma.commonStoreCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }),
prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }),
]);
for (const tpl of DEFAULT_PRODUCT_DETAIL_TEMPLATES) {
await prisma.commonProductDetailTemplate.create({
data: {
code: tpl.code,
name: tpl.name,
description: tpl.description,
aromaType: tpl.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG' | null,
storyTitle: tpl.storyTitle,
storyText: tpl.storyText,
features: tpl.features,
detailImageUrls: [],
suggestedDetailImageCount: tpl.suggestedDetailImageCount,
sortOrder: tpl.sortOrder,
status: 'ACTIVE',
},
});
}
const productDefs = [
{ skuCode: 'JZ-10', name: '酒祖杜康(国标特级10', subtitle: '清香型 53度', price: 128, sortOrder: 1, img: 'https://picsum.photos/seed/jiuzu10/400/400' },
{ skuCode: 'JZ-15', name: '酒祖杜康(国标特级15', subtitle: '清香型 53度', price: 168, sortOrder: 2, img: 'https://picsum.photos/seed/jiuzu15/400/400' },
{ skuCode: 'JZ-20', name: '酒祖杜康(国标特级20', subtitle: '清香型 53度', price: 298, sortOrder: 3, img: 'https://picsum.photos/seed/jiuzu20/400/400' },
{ skuCode: 'JZ-30', name: '酒祖杜康(国标特级30', subtitle: '清香型 53度', price: 498, sortOrder: 4, img: 'https://picsum.photos/seed/jiuzu30/400/400' },
];
const products = [];
for (const [i, def] of productDefs.entries()) {
const product = await prisma.commonProductItem.create({
data: {
skuCode: def.skuCode,
barcode69: `69000000000${i + 1}`,
name: def.name,
subtitle: def.subtitle,
aromaType: 'QINGXIANG',
spec: '500ml | 53度',
price: def.price,
benefitAmount: def.price,
status: 'ON_SALE',
sortOrder: def.sortOrder,
},
});
const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img);
await prisma.commonProductItem.update({
where: { id: product.id },
data: { coverResourceId: cover.id },
});
products.push(product);
}
await prisma.partnerAccount.create({
data: {
partnerId: partner.id,
phone: '13700000001',
name: '郑州合伙人主账号',
isPrimary: 1,
staffRole: 'PARTNER',
},
});
const primaryAccount = await prisma.partnerAccount.findUniqueOrThrow({
where: { phone: '13700000001' },
});
await prisma.partnerAccount.create({
data: {
partnerId: partner.id,
phone: '13700000002',
name: '拓店员小李',
isPrimary: 0,
parentAccountId: primaryAccount.id,
staffRole: 'INTERNAL',
status: 'DISABLED',
},
});
const storeDefs = [
{
name: '郑州老城店',
phone: '13910000001',
district: '金水区',
address: '花园路100号',
intro: '正宗河南菜,欢迎核销好客权益',
img: 'https://picsum.photos/seed/store1/400/300',
categoryId: categories[0].id,
settlementRate: 0.6,
withAccount: true,
},
{
name: '郑州美食城店',
phone: '13910000002',
district: '二七区',
address: '大学路200号',
intro: '地方特色餐饮',
img: 'https://picsum.photos/seed/store2/400/300',
categoryId: categories[1].id,
settlementRate: 0.65,
withAccount: false,
},
];
const createdStores: { id: bigint; name: string }[] = [];
for (const def of storeDefs) {
const store = await prisma.store.create({
data: {
cityId: city.id,
partnerId: partner.id,
partnerAccountId: primaryAccount.id,
categoryId: def.categoryId,
name: def.name,
phone: def.phone,
province: '河南省',
cityName: '郑州市',
district: def.district,
address: def.address,
intro: def.intro,
settlementRate: def.settlementRate,
status: 'OPEN',
openTime: '10:00',
closeTime: '22:00',
bankAccountName: def.name,
bankAccountNo: '6222029876543210',
bankBranch: '建设银行郑州分行',
},
});
const cover = await createMockResource(ResourceOwnerType.STORE, store.id, ResourceBizType.COVER, def.img);
await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
if (def.withAccount) {
await prisma.storeAccount.create({
data: { storeId: store.id, phone: def.phone, name: def.name },
});
}
createdStores.push({ id: store.id, name: def.name });
}
const weekStart = (() => {
const d = new Date();
d.setHours(12, 0, 0, 0);
const day = d.getDay();
const diff = day === 0 ? 6 : day - 1;
d.setDate(d.getDate() - diff);
d.setHours(10, 0, 0, 0);
return d;
})();
const newWeekStore = await prisma.store.create({
data: {
cityId: city.id,
partnerId: partner.id,
partnerAccountId: primaryAccount.id,
categoryId: categories[0].id,
name: '本周新签体验店',
phone: '13910000003',
province: '河南省',
cityName: '郑州市',
district: '中原区',
address: '建设路88号',
intro: '本周新签约门店',
settlementRate: 0.6,
status: 'OPEN',
openTime: '10:00',
closeTime: '22:00',
bankAccountName: '本周新签体验店',
bankAccountNo: '6222029876543211',
bankBranch: '农业银行郑州分行',
createdAt: new Date(weekStart.getTime() + 2 * 24 * 60 * 60 * 1000),
},
});
createdStores.push({ id: newWeekStore.id, name: newWeekStore.name });
await prisma.user.create({
data: {
userNo: 'DK88293401',
phone: '13800000001',
phoneVerifiedAt: new Date(),
nickname: '测试用户',
cityPreference: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
locateCityCode: '410100',
locateDistrict: '金水区',
},
},
},
});
await prisma.hqAccount.create({
data: {
phone: '13600000001',
loginName: 'admin',
passwordHash: hashPassword('dukang@123!'),
name: '总部管理员',
adminRole: 'SUPER_ADMIN',
},
});
await prisma.commonPromoCode.create({
data: {
code: 'DKHQ001',
name: '总部品鉴会',
status: 'ACTIVE',
},
});
await prisma.commonPromoCode.create({
data: {
code: 'DKDEMO1',
name: '郑州品鉴会演示',
status: 'ACTIVE',
},
});
const user = await prisma.user.findUniqueOrThrow({ where: { phone: '13800000001' } });
const product = products[0];
const orderDefs = [
{ dayOffset: 0, payAmount: 1198, quantity: 2 },
{ dayOffset: 1, payAmount: 599, quantity: 1 },
{ dayOffset: 2, payAmount: 1760, quantity: 2 },
{ dayOffset: 4, payAmount: 1299, quantity: 1 },
{ dayOffset: 5, payAmount: 880, quantity: 1 },
];
const coupons: { id: bigint; balance: number }[] = [];
for (const [index, def] of orderDefs.entries()) {
const paidAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 14 * 60 * 60 * 1000);
const listAmount = def.payAmount;
const order = await prisma.order.create({
data: {
orderNo: `WK${Date.now()}${index}`,
userId: user.id,
cityId: city.id,
status: 'COMPLETED',
payStatus: 'PAID',
deliveryType: 'LOCAL',
productId: product.id,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec,
quantity: def.quantity,
listUnitPrice: product.price,
listAmount,
productAmount: listAmount,
payAmount: listAmount,
benefitAmount: listAmount,
receiverName: '测试用户',
receiverPhone: user.phone,
receiverPhone: user.phone!,
receiverAddress: '郑州市金水区测试路1号',
receiverProvince: '河南省',
receiverCity: '郑州市',
receiverDistrict: '金水区',
paidAt,
completedAt: paidAt,
partnerAccountIdAtPay: primaryAccount.id,
orderCommissionRateAtPay: 0,
},
});
const coupon = await prisma.benefitCoupon.create({
data: {
couponNo: `CPN${Date.now()}${index}`,
userId: user.id,
orderId: order.id,
totalAmount: listAmount,
balance: listAmount,
sourceProduct: product.name,
},
});
coupons.push({ id: coupon.id, balance: listAmount });
}
const redeemDefs = [
{ storeIndex: 0, amount: 42800, dayOffset: 1 },
{ storeIndex: 1, amount: 38400, dayOffset: 2 },
{ storeIndex: 2, amount: 31200, dayOffset: 3 },
{ storeIndex: 0, amount: 42800, dayOffset: 1, settlementRate: 0.6 },
{ storeIndex: 1, amount: 38400, dayOffset: 2, settlementRate: 0.65 },
{ storeIndex: 2, amount: 31200, dayOffset: 3, settlementRate: 0.6 },
];
for (const [index, def] of redeemDefs.entries()) {
const coupon = coupons[index];
const store = createdStores[def.storeIndex];
const createdAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 16 * 60 * 60 * 1000);
const settleAmount = Math.round(def.amount * def.settlementRate * 100) / 100;
await prisma.redeemRecord.create({
data: {
redeemNo: `RD${Date.now()}${index}`,
userId: user.id,
couponId: coupon.id,
storeId: store.id,
amount: def.amount,
settleAmount: Math.round(def.amount * 0.6 * 100) / 100,
settleAmount,
createdAt,
},
});
await prisma.benefitCoupon.update({
where: { id: coupon.id },
data: {
usedAmount: def.amount,
balance: Math.max(0, coupon.balance - def.amount),
},
});
}
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
await prisma.partnerBill.create({
data: {
billNo: `PB${Date.now()}`,
partnerId: partner.id,
partnerAccountId: primaryAccount.id,
periodStart,
periodEnd,
orderCommission: 1200,
redeemCommission: 800,
totalAmount: 2000,
status: 'CONFIRMED',
confirmedAt: now,
},
});
console.log('Seed complete:', {
city: city.name,
warehouse: warehouse.name,
primaryAccount: primaryAccount.phone,
detailTemplates: DEFAULT_PRODUCT_DETAIL_TEMPLATES.length,
products: products.length,
stores: storeDefs.length,
stores: createdStores.length,
testPhones: {
user: '13800000001',
partner: '13700000001',
partnerStaff: '13700000002',
hq: '13600000001',
},
});
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+2
View File
@@ -15,6 +15,7 @@ import { SettlementModule } from './modules/settlement/settlement.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { JobsModule } from './jobs/jobs.module';
import { OpsModule } from './modules/ops/ops.module';
import { CityScopeModule } from './modules/city-scope/city-scope.module';
import { CommonModule } from './modules/common/common.module';
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
import { CallbacksModule } from './callbacks/callbacks.module';
@@ -41,6 +42,7 @@ import { CallbacksModule } from './callbacks/callbacks.module';
AnalyticsModule,
JobsModule,
OpsModule,
CityScopeModule,
CommonModule,
HqOperationModule,
CallbacksModule,
@@ -2,6 +2,13 @@
export const HqOperationAction = {
CITY_CREATE: 'CITY_CREATE',
CITY_UPDATE: 'CITY_UPDATE',
CITY_DELETE: 'CITY_DELETE',
CITY_PARTNER_BIND: 'CITY_PARTNER_BIND',
CITY_PARTNER_UPDATE: 'CITY_PARTNER_UPDATE',
CITY_PARTNER_UNBIND: 'CITY_PARTNER_UNBIND',
WAREHOUSE_CREATE: 'WAREHOUSE_CREATE',
WAREHOUSE_UPDATE: 'WAREHOUSE_UPDATE',
WAREHOUSE_DELETE: 'WAREHOUSE_DELETE',
PARTNER_CREATE: 'PARTNER_CREATE',
PARTNER_UPDATE: 'PARTNER_UPDATE',
PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE',
@@ -47,6 +54,13 @@ export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOp
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.CITY_CREATE]: '新增开城城市',
[HqOperationAction.CITY_UPDATE]: '编辑开城城市',
[HqOperationAction.CITY_DELETE]: '删除开城城市',
[HqOperationAction.CITY_PARTNER_BIND]: '绑定城市合伙人',
[HqOperationAction.CITY_PARTNER_UPDATE]: '编辑城市合伙人绑定',
[HqOperationAction.CITY_PARTNER_UNBIND]: '解绑城市合伙人',
[HqOperationAction.WAREHOUSE_CREATE]: '新增城市仓库',
[HqOperationAction.WAREHOUSE_UPDATE]: '编辑城市仓库',
[HqOperationAction.WAREHOUSE_DELETE]: '删除城市仓库',
[HqOperationAction.PARTNER_CREATE]: '新增城市合伙人',
[HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人',
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
@@ -17,8 +17,8 @@ export type TrackStoreEventInput = TrackEventInput & {
};
export type TrackPartnerEventInput = TrackEventInput & {
partnerAccountId?: bigint;
partnerId: bigint;
/** 主账号 ID,用于合伙人维度聚合 */
partnerAccountId: bigint;
};
@Injectable()
@@ -64,21 +64,21 @@ export class AnalyticsService {
}
async trackPartnerOne(
partnerAccountId: bigint | undefined,
actorAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackPartnerEventInput,
) {
await this.prisma.logPartnerAnalytics.create({
data: this.toPartnerRow(partnerAccountId, clientApp, event),
data: this.toPartnerRow(actorAccountId, clientApp, event),
});
}
trackPartnerOneSafe(
partnerAccountId: bigint | undefined,
actorAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackPartnerEventInput,
) {
void this.trackPartnerOne(partnerAccountId, clientApp, event).catch(() => {});
void this.trackPartnerOne(actorAccountId, clientApp, event).catch(() => {});
}
private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
@@ -110,17 +110,16 @@ export class AnalyticsService {
}
private toPartnerRow(
partnerAccountId: bigint | undefined,
actorAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackPartnerEventInput,
) {
return {
partnerAccountId,
partnerId: event.partnerId,
partnerAccountId: event.partnerAccountId,
eventName: event.eventName,
clientApp: clientApp as ClientApp,
refType: event.refType,
refId: event.refId,
refId: event.refId ?? actorAccountId,
extraJson: event.extraJson as never,
};
}
@@ -10,10 +10,28 @@ export class CatalogService {
async listCities() {
const cities = await this.prisma.commonCity.findMany({
where: { status: 'ACTIVE' },
include: { partner: { select: { companyName: true } } },
include: {
partnerAccounts: {
where: { isPrimary: 1, bindingStatus: 'ACTIVE' },
orderBy: { createdAt: 'asc' },
select: { id: true, companyName: true, scopeType: true },
},
},
orderBy: { name: 'asc' },
});
return serializeBigInt(cities);
return serializeBigInt(
cities.map((city) => ({
...city,
partnerBindingCount: city.partnerAccounts.length,
partnerBindings: city.partnerAccounts.map((bp) => ({
partnerAccountId: bp.id.toString(),
partnerId: bp.id.toString(),
companyName: bp.companyName,
scopeType: bp.scopeType,
})),
partnerAccounts: undefined,
})),
);
}
async listProducts(aromaType?: string, cityCode?: string) {
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PartnerCityService } from './partner-city.service';
import { CityWarehouseService } from './city-warehouse.service';
@Module({
providers: [PartnerCityService, CityWarehouseService],
exports: [PartnerCityService, CityWarehouseService],
})
export class CityScopeModule {}
@@ -0,0 +1,207 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, WarehouseManagerType, WarehouseStatus } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from './partner-city.service';
import type { AdminCityWarehousesQueryDto } from '../ops/dto/admin-query.dto';
export type CreateCityWarehouseInput = {
name: string;
address: string;
contactName: string;
contactPhone: string;
managerType: WarehouseManagerType;
partnerAccountId?: bigint;
status?: WarehouseStatus;
};
export type UpdateCityWarehouseInput = Partial<CreateCityWarehouseInput>;
@Injectable()
export class CityWarehouseService {
constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
) {}
async listByCity(cityId: bigint) {
const rows = await this.prisma.cityWarehouse.findMany({
where: { cityId },
include: {
partnerAccount: { select: { id: true, companyName: true } },
},
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toDto(row));
}
async listAll(query: AdminCityWarehousesQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CityWarehouseWhereInput = {};
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.name) where.name = { contains: query.name };
if (query.managerType) where.managerType = query.managerType as Prisma.EnumWarehouseManagerTypeFilter['equals'];
if (query.status) where.status = query.status as Prisma.EnumWarehouseStatusFilter['equals'];
const [rows, total] = await Promise.all([
this.prisma.cityWarehouse.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
include: {
partnerAccount: { select: { id: true, companyName: true } },
city: { select: { id: true, name: true, code: true } },
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.cityWarehouse.count({ where }),
]);
return serializeBigInt({
items: rows.map((row) => ({
...this.toDto(row),
cityName: row.city.name,
cityCode: row.city.code,
})),
total,
page,
pageSize,
});
}
async create(cityId: bigint, input: CreateCityWarehouseInput) {
await this.assertCityExists(cityId);
await this.validateManager(input.managerType, input.partnerAccountId, cityId);
const row = await this.prisma.cityWarehouse.create({
data: {
cityId,
name: input.name.trim(),
address: input.address.trim(),
contactName: input.contactName.trim(),
contactPhone: input.contactPhone.trim(),
managerType: input.managerType,
partnerAccountId: input.managerType === 'PARTNER' ? input.partnerAccountId : null,
status: input.status ?? 'ACTIVE',
},
include: {
partnerAccount: { select: { id: true, companyName: true } },
},
});
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
return this.toDto(row);
}
async update(id: bigint, input: UpdateCityWarehouseInput) {
const current = await this.prisma.cityWarehouse.findUnique({ where: { id } });
if (!current) throw new NotFoundException('仓库不存在');
const managerType = input.managerType ?? (current.managerType as WarehouseManagerType);
const partnerAccountId =
managerType === 'PARTNER'
? input.partnerAccountId ?? current.partnerAccountId ?? undefined
: null;
await this.validateManager(managerType, partnerAccountId ?? undefined, current.cityId);
const row = await this.prisma.cityWarehouse.update({
where: { id },
data: {
...(input.name !== undefined ? { name: input.name.trim() } : {}),
...(input.address !== undefined ? { address: input.address.trim() } : {}),
...(input.contactName !== undefined ? { contactName: input.contactName.trim() } : {}),
...(input.contactPhone !== undefined ? { contactPhone: input.contactPhone.trim() } : {}),
...(input.managerType !== undefined ? { managerType: input.managerType } : {}),
...(input.managerType !== undefined || input.partnerAccountId !== undefined
? { partnerAccountId: managerType === 'PARTNER' ? partnerAccountId : null }
: {}),
...(input.status !== undefined ? { status: input.status } : {}),
},
include: {
partnerAccount: { select: { id: true, companyName: true } },
},
});
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
return this.toDto(row);
}
async remove(id: bigint) {
const current = await this.prisma.cityWarehouse.findUnique({ where: { id } });
if (!current) throw new NotFoundException('仓库不存在');
await this.prisma.partnerAccount.updateMany({
where: { managedWarehouseId: id },
data: { managedWarehouseId: null },
});
await this.prisma.cityWarehouse.delete({ where: { id } });
return { ok: true, id: id.toString() };
}
private async syncManagedWarehouse(
warehouseId: bigint,
managerType: WarehouseManagerType,
partnerAccountId: bigint | null,
) {
await this.prisma.partnerAccount.updateMany({
where: { managedWarehouseId: warehouseId },
data: { managedWarehouseId: null },
});
if (managerType === 'PARTNER' && partnerAccountId) {
await this.prisma.partnerAccount.updateMany({
where: { id: partnerAccountId, managedWarehouseId: { not: warehouseId } },
data: { managedWarehouseId: null },
});
await this.prisma.partnerAccount.update({
where: { id: partnerAccountId },
data: { managedWarehouseId: warehouseId },
});
}
}
private validateManager(
managerType: WarehouseManagerType,
partnerAccountId: bigint | undefined,
cityId: bigint,
) {
if (managerType === 'PARTNER') {
if (!partnerAccountId) throw new BadRequestException('合伙人管仓须指定合伙人');
return this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, cityId);
}
}
private async assertCityExists(cityId: bigint) {
const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } });
if (!city) throw new NotFoundException('开城城市不存在');
}
private toDto(row: {
id: bigint;
cityId: bigint;
name: string;
address: string;
contactName: string;
contactPhone: string;
managerType: string;
partnerAccountId: bigint | null;
status: string;
createdAt: Date;
updatedAt: Date;
partnerAccount?: { id: bigint; companyName: string | null } | null;
}) {
return serializeBigInt({
id: row.id.toString(),
cityId: row.cityId.toString(),
name: row.name,
address: row.address,
contactName: row.contactName,
contactPhone: row.contactPhone,
managerType: row.managerType,
partnerAccountId: row.partnerAccountId?.toString() ?? null,
partnerCompanyName: row.partnerAccount?.companyName ?? null,
status: row.status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
}
}
@@ -0,0 +1,164 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, CityPartnerScopeType, CityPartnerStatus } from '@prisma/client';
import { resolveOrderCityPartner, validatePartnerCityBinding } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
const PRIMARY_WHERE = { isPrimary: 1 } as const;
@Injectable()
export class PartnerCityService {
constructor(private readonly prisma: PrismaService) {}
async listByCity(cityId: bigint) {
const rows = await this.prisma.partnerAccount.findMany({
where: { ...PRIMARY_WHERE, cityId },
include: { city: { select: { id: true, code: true, name: true } } },
orderBy: [{ scopeType: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((row) => this.toDto(row));
}
async listCityIdsForPartnerAccount(partnerAccountId: bigint): Promise<bigint[]> {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
if (!primary.cityId) return [];
return [primary.cityId];
}
async assertPartnerAccountBoundToCity(partnerAccountId: bigint, cityId: bigint) {
const row = await this.prisma.partnerAccount.findFirst({
where: {
id: partnerAccountId,
...PRIMARY_WHERE,
cityId,
bindingStatus: 'ACTIVE',
},
});
if (!row) {
throw new BadRequestException('合伙人未绑定该开城城市');
}
return row;
}
/** @deprecated */
async assertPartnerBoundToCity(partnerAccountId: bigint, cityId: bigint) {
return this.assertPartnerAccountBoundToCity(partnerAccountId, cityId);
}
async resolveForOrder(cityId: bigint, receiverDistrict?: string | null) {
const bindings = await this.prisma.partnerAccount.findMany({
where: { ...PRIMARY_WHERE, cityId, bindingStatus: 'ACTIVE' },
});
const ref = resolveOrderCityPartner(
bindings.map((b) => ({
id: b.id.toString(),
partnerAccountId: b.id.toString(),
scopeType: b.scopeType as CityPartnerScopeType,
districtCodes: this.parseDistrictCodes(b.districtCodes),
orderCommissionRate: Number(b.orderCommissionRate ?? 0),
redeemCommissionRate: Number(b.redeemCommissionRate ?? 0.03),
bindingStatus: b.bindingStatus as CityPartnerStatus,
})),
receiverDistrict,
);
if (!ref) return null;
return {
partnerAccountId: BigInt(ref.partnerAccountId),
orderCommissionRate: ref.orderCommissionRate,
redeemCommissionRate: ref.redeemCommissionRate,
};
}
async validatePrimaryBinding(
cityId: bigint,
input: {
partnerAccountId?: string;
scopeType: CityPartnerScopeType;
districtCodes?: string[];
},
excludeId?: bigint,
) {
const existing = await this.prisma.partnerAccount.findMany({
where: { ...PRIMARY_WHERE, cityId, ...(excludeId ? { NOT: { id: excludeId } } : {}) },
});
const validation = validatePartnerCityBinding(
existing.map((r) => ({
id: r.id.toString(),
partnerAccountId: r.id.toString(),
scopeType: r.scopeType as CityPartnerScopeType,
districtCodes: this.parseDistrictCodes(r.districtCodes),
})),
{
partnerAccountId: input.partnerAccountId ?? 'new',
scopeType: input.scopeType,
districtCodes: input.districtCodes,
},
excludeId?.toString(),
);
if (!validation.ok) throw new BadRequestException(validation.message);
}
async buildPartnerOrderWhere(partnerAccountId: bigint): Promise<Prisma.OrderWhereInput> {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
if (!primary.cityId) return { id: -1n };
return { cityId: primary.cityId };
}
async buildPartnerCityWhere(partnerAccountId: bigint): Promise<Prisma.CommonCityWhereInput> {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
if (!primary.cityId) return { id: -1n };
return { id: primary.cityId };
}
async resolvePrimaryAccount(accountId: bigint) {
const account = await this.prisma.partnerAccount.findUnique({ where: { id: accountId } });
if (!account) throw new NotFoundException('合伙人账号不存在');
if (account.isPrimary === 1) return account;
if (!account.parentAccountId) {
throw new BadRequestException('子账号缺少主账号');
}
return this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: account.parentAccountId } });
}
parseDistrictCodes(value: Prisma.JsonValue | null): string[] | null {
if (!value || !Array.isArray(value)) return null;
return value.map((v) => String(v));
}
toDto(row: {
id: bigint;
cityId: bigint | null;
companyName: string | null;
phone: string;
name: string;
scopeType: string | null;
districtCodes: Prisma.JsonValue | null;
orderCommissionRate: Prisma.Decimal | null;
redeemCommissionRate: Prisma.Decimal | null;
bindingStatus: string | null;
managedWarehouseId: bigint | null;
status: string;
createdAt: Date;
updatedAt: Date;
city?: { id: bigint; code: string; name: string } | null;
}) {
return serializeBigInt({
id: row.id.toString(),
cityId: row.cityId?.toString() ?? null,
cityName: row.city?.name ?? null,
cityCode: row.city?.code ?? null,
companyName: row.companyName,
phone: row.phone,
name: row.name,
scopeType: row.scopeType,
districtCodes: this.parseDistrictCodes(row.districtCodes),
orderCommissionRate: Number(row.orderCommissionRate ?? 0),
redeemCommissionRate: Number(row.redeemCommissionRate ?? 0.03),
bindingStatus: row.bindingStatus,
managedWarehouseId: row.managedWarehouseId?.toString() ?? null,
status: row.status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
}
}
@@ -168,15 +168,15 @@ export class AuthService {
}
private trackPartnerEvent(
partnerAccountId: bigint | undefined,
partnerId: bigint,
actorAccountId: bigint | undefined,
primaryAccountId: bigint,
clientApp: ClientApp | string,
eventName: string,
extraJson?: Record<string, unknown>,
ref?: { refType?: string; refId?: bigint },
) {
this.analyticsService.trackPartnerOneSafe(partnerAccountId, clientApp, {
partnerId,
this.analyticsService.trackPartnerOneSafe(actorAccountId, clientApp, {
partnerAccountId: primaryAccountId,
eventName,
refType: ref?.refType,
refId: ref?.refId,
@@ -184,10 +184,42 @@ export class AuthService {
});
}
private async resolvePrimaryAccount(accountId: bigint) {
const account = await this.prisma.partnerAccount.findUnique({ where: { id: accountId } });
if (!account) throw new NotFoundException('合伙人账号不存在');
if (account.isPrimary === 1) return account;
if (!account.parentAccountId) {
throw new BadRequestException('子账号缺少主账号');
}
return this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: account.parentAccountId } });
}
private partnerTokenPayload(
account: {
id: bigint;
name: string;
phone: string;
isPrimary: number;
staffRole: string | null;
permissions?: unknown;
},
primary: { id: bigint; companyName: string | null },
) {
return {
id: account.id.toString(),
primaryAccountId: primary.id.toString(),
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
staffRole: account.staffRole ?? undefined,
companyName: primary.companyName ?? undefined,
permissions: Array.isArray(account.permissions) ? account.permissions : undefined,
};
}
private async assertPartnerAccountByPhone(phone: string) {
const account = await this.prisma.partnerAccount.findUnique({
where: { phone },
include: { partner: true },
});
if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
@@ -197,11 +229,12 @@ export class AuthService {
async checkPartnerPhone(phone: string) {
const normalizedPhone = this.assertMobilePhone(phone);
const account = await this.assertPartnerAccountByPhone(normalizedPhone);
const primary = await this.resolvePrimaryAccount(account.id);
return {
ok: true,
maskedPhone: this.maskPhone(normalizedPhone),
name: account.name,
companyName: account.partner.companyName,
companyName: primary.companyName,
};
}
@@ -306,12 +339,13 @@ export class AuthService {
) {
const partnerAccount = await this.prisma.partnerAccount.findUnique({
where: { id: actorRef.refId },
select: { id: true, partnerId: true },
select: { id: true, isPrimary: true, parentAccountId: true },
});
if (partnerAccount) {
const primary = await this.resolvePrimaryAccount(partnerAccount.id);
this.trackPartnerEvent(
partnerAccount.id,
partnerAccount.partnerId,
primary.id,
clientApp,
'partner_sms_send',
{
@@ -415,20 +449,12 @@ export class AuthService {
private async buildPartnerSessionResponse(accountId: bigint, clientApp: ClientApp) {
const account = await this.prisma.partnerAccount.findUnique({
where: { id: accountId },
include: { partner: true },
});
if (!account || account.status !== 'ACTIVE') {
throw new UnauthorizedException('Invalid refresh token');
}
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
id: account.id.toString(),
partnerId: account.partnerId.toString(),
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
staffRole: account.staffRole ?? undefined,
companyName: account.partner.companyName,
});
const primary = await this.resolvePrimaryAccount(account.id);
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
}
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
@@ -601,7 +627,8 @@ export class AuthService {
} catch (err) {
const account = await this.prisma.partnerAccount.findUnique({ where: { phone: normalizedPhone } });
if (account) {
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_sms_verify_fail', {
const primary = await this.resolvePrimaryAccount(account.id);
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_sms_verify_fail', {
phone: this.maskPhone(normalizedPhone),
reason: err instanceof BadRequestException ? err.message : '验证码错误',
});
@@ -610,29 +637,21 @@ export class AuthService {
}
const account = await this.prisma.partnerAccount.findUnique({
where: { phone: normalizedPhone },
include: { partner: true },
});
if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
const primary = await this.resolvePrimaryAccount(account.id);
await this.prisma.partnerAccount.update({
where: { id: account.id },
data: { lastLoginAt: new Date() },
});
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_sms_login', {
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_sms_login', {
phone: this.maskPhone(normalizedPhone),
});
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_login_success', {
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_login_success', {
method: 'sms',
});
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
id: account.id.toString(),
partnerId: account.partnerId.toString(),
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
staffRole: account.staffRole ?? undefined,
companyName: account.partner.companyName,
});
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
}
async loginHq(phone: string, code: string, clientApp: ClientApp) {
@@ -734,9 +753,14 @@ export class AuthService {
if (actorType === 'PARTNER') {
const account = await this.prisma.partnerAccount.findUnique({
where: { id: actorId },
include: { partner: true },
});
return serializeBigInt(account);
if (!account) return null;
const primary = await this.resolvePrimaryAccount(account.id);
return serializeBigInt({
...account,
primaryAccountId: primary.id,
companyName: primary.companyName,
});
}
if (actorType === 'HQ') {
const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } });
@@ -1102,7 +1126,6 @@ export class AuthService {
const account = await this.prisma.partnerAccount.findUnique({
where: { id: partnerAccountId },
include: { partner: true },
});
if (!account) throw new BadRequestException('合伙人账号不存在');
@@ -1120,19 +1143,12 @@ export class AuthService {
wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(),
},
include: { partner: true },
});
const primary = await this.resolvePrimaryAccount(updated.id);
this.trackPartnerEvent(updated.id, updated.partnerId, clientApp, 'partner_wechat_bind', { platform });
this.trackPartnerEvent(updated.id, primary.id, clientApp, 'partner_wechat_bind', { platform });
return this.issueToken('PARTNER', updated.id, clientApp, false, undefined, undefined, {
id: updated.id.toString(),
partnerId: updated.partnerId.toString(),
name: updated.name,
phone: updated.phone,
isPrimary: updated.isPrimary === 1,
companyName: updated.partner.companyName,
});
return this.issueToken('PARTNER', updated.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(updated, primary));
}
async loginPartnerWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
@@ -1144,7 +1160,6 @@ export class AuthService {
let account = await this.prisma.partnerAccount.findFirst({
where: { wxOpenId: session.openId },
include: { partner: true },
});
if (!account && this.wechatProvider.isMock()) {
@@ -1152,7 +1167,6 @@ export class AuthService {
account = await this.prisma.partnerAccount.findFirst({
where: { status: 'ACTIVE' },
orderBy: [{ isPrimary: 'desc' }, { id: 'asc' }],
include: { partner: true },
});
}
@@ -1167,23 +1181,15 @@ export class AuthService {
wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(),
},
include: { partner: true },
});
const primary = await this.resolvePrimaryAccount(account.id);
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_wechat_login', { platform });
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_login_success', {
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_wechat_login', { platform });
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_login_success', {
method: 'wechat',
});
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
id: account.id.toString(),
partnerId: account.partnerId.toString(),
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
staffRole: account.staffRole ?? undefined,
companyName: account.partner.companyName,
});
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
}
private async mergeUsers(guestId: bigint, primaryId: bigint): Promise<UserRow> {
@@ -1,4 +1,4 @@
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { AccountStatus, PartnerStaffRole } from '@dukang/shared-types';
export class CreatePartnerStaffDto {
@@ -15,6 +15,11 @@ export class CreatePartnerStaffDto {
@IsString()
@IsIn(Object.values(PartnerStaffRole))
staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
}
export class UpdatePartnerStaffDto {
@@ -27,6 +32,11 @@ export class UpdatePartnerStaffDto {
@IsOptional()
staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
@IsString()
@IsIn(Object.values(AccountStatus))
@IsOptional()
@@ -17,7 +17,7 @@ export class PartnerStaffController {
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreatePartnerStaffDto) {
return this.staffService.createStaff(user.actorId, dto);
return this.staffService.createStaff(user, dto);
}
@Put(':id')
@@ -26,11 +26,11 @@ export class PartnerStaffController {
@Param('id') id: string,
@Body() dto: UpdatePartnerStaffDto,
) {
return this.staffService.updateStaff(user.actorId, BigInt(id), dto);
return this.staffService.updateStaff(user, BigInt(id), dto);
}
@Delete(':id')
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.staffService.deleteStaff(user.actorId, BigInt(id));
return this.staffService.deleteStaff(user, BigInt(id));
}
}
@@ -1,114 +1,182 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PartnerStaffRole } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
@Injectable()
export class PartnerStaffService {
constructor(private readonly prisma: PrismaService) {}
async listStaff(parentAccountId: bigint) {
const rows = await this.prisma.partnerAccount.findMany({
where: { parentAccountId },
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toStaffItem(row));
}
async createStaff(parentAccountId: bigint, dto: CreatePartnerStaffDto) {
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: parentAccountId },
});
if (parent.isPrimary !== 1) {
throw new BadRequestException('仅主账号可添加子账号');
}
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的手机号码');
}
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (existing) throw new BadRequestException('该手机号已被使用');
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
const account = await this.prisma.partnerAccount.create({
data: {
partnerId: parent.partnerId,
phone,
name,
staffRole: (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL,
isPrimary: 0,
parentAccountId: parent.id,
status: 'DISABLED',
},
});
return this.toStaffItem(account);
}
async updateStaff(parentAccountId: bigint, staffId: bigint, dto: UpdatePartnerStaffDto) {
const staff = await this.assertStaffOwned(parentAccountId, staffId);
const data: Record<string, unknown> = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
data.name = name;
}
if (dto.staffRole !== undefined) {
data.staffRole = dto.staffRole as PartnerStaffRole;
}
if (dto.status !== undefined) {
data.status = dto.status;
}
const updated = await this.prisma.partnerAccount.update({
where: { id: staff.id },
data,
});
return this.toStaffItem(updated);
}
async deleteStaff(parentAccountId: bigint, staffId: bigint) {
const staff = await this.assertStaffOwned(parentAccountId, staffId);
await this.prisma.partnerAccount.delete({ where: { id: staff.id } });
return { ok: true };
}
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
const staff = await this.prisma.partnerAccount.findFirst({
where: { id: staffId, parentAccountId },
});
if (!staff) throw new NotFoundException('子账号不存在');
return staff;
}
private toStaffItem(row: {
id: bigint;
name: string;
phone: string;
staffRole: string | null;
status: string;
lastLoginAt: Date | null;
}) {
return serializeBigInt({
id: row.id.toString(),
name: row.name,
phone: this.maskPhone(row.phone),
staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL,
status: row.status,
lastLoginAt: row.lastLoginAt?.toISOString(),
});
}
private maskPhone(phone: string): string {
if (phone.length !== 11) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
}
}
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PartnerStaffRole } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AnalyticsService } from '../analytics/analytics.service';
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
@Injectable()
export class PartnerStaffService {
constructor(
private readonly prisma: PrismaService,
private readonly analytics: AnalyticsService,
) {}
async listStaff(parentAccountId: bigint) {
const rows = await this.prisma.partnerAccount.findMany({
where: { parentAccountId },
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toStaffItem(row));
}
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
const parentAccountId = actor.actorId;
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: parentAccountId },
});
if (parent.isPrimary !== 1) {
throw new BadRequestException('仅主账号可添加子账号');
}
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的手机号码');
}
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (existing) throw new BadRequestException('该手机号已被使用');
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL;
const account = await this.prisma.partnerAccount.create({
data: {
phone,
name,
staffRole,
permissions: dto.permissions ?? undefined,
isPrimary: 0,
parentAccountId: parent.id,
status: 'DISABLED',
},
});
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
name,
phone: this.maskPhone(phone),
staffRole,
status: account.status,
});
return this.toStaffItem(account);
}
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdatePartnerStaffDto) {
const parentAccountId = actor.actorId;
const staff = await this.assertStaffOwned(parentAccountId, staffId);
const before = {
name: staff.name,
staffRole: staff.staffRole,
status: staff.status,
};
const data: Record<string, unknown> = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
data.name = name;
}
if (dto.staffRole !== undefined) {
data.staffRole = dto.staffRole as PartnerStaffRole;
}
if (dto.permissions !== undefined) {
data.permissions = dto.permissions;
}
@@ -1,13 +1,18 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service';
import type { AdminCitiesQueryDto } from './dto/admin-query.dto';
import type { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
@Injectable()
export class AdminCitiesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
) {}
async list(query: AdminCitiesQueryDto) {
const page = query.page ?? 1;
@@ -16,7 +21,9 @@ export class AdminCitiesService {
if (query.name) where.name = { contains: query.name };
if (query.code) where.code = { contains: query.code };
if (query.status) where.status = query.status as Prisma.EnumCityStatusFilter['equals'];
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.partnerId) {
where.partnerAccounts = { some: { id: BigInt(query.partnerId), isPrimary: 1 } };
}
const [items, total] = await Promise.all([
this.prisma.commonCity.findMany({
@@ -25,8 +32,12 @@ export class AdminCitiesService {
skip: (page - 1) * pageSize,
take: pageSize,
include: {
partner: { select: { id: true, companyName: true } },
_count: { select: { stores: true, orders: true } },
partnerAccounts: {
where: { isPrimary: 1 },
select: { id: true, companyName: true, scopeType: true, bindingStatus: true },
orderBy: { createdAt: 'asc' },
},
_count: { select: { stores: true, orders: true, partnerAccounts: true, warehouses: true } },
},
}),
this.prisma.commonCity.count({ where }),
@@ -34,8 +45,18 @@ export class AdminCitiesService {
return serializeBigInt({
items: items.map((c) => ({
...c,
partnerBindings: c.partnerAccounts.map((bp) => ({
id: bp.id.toString(),
partnerAccountId: bp.id.toString(),
partnerCompanyName: bp.companyName,
scopeType: bp.scopeType,
status: bp.bindingStatus,
})),
partnerAccounts: undefined,
storeCount: c._count.stores,
orderCount: c._count.orders,
partnerBindingCount: c.partnerAccounts.length,
warehouseCount: c._count.warehouses,
_count: undefined,
})),
total,
@@ -48,13 +69,22 @@ export class AdminCitiesService {
const city = await this.prisma.commonCity.findUnique({
where: { id },
include: {
partner: true,
commissionRule: true,
warehouses: {
include: { partnerAccount: { select: { id: true, companyName: true } } },
orderBy: { createdAt: 'desc' },
},
_count: { select: { stores: true, orders: true } },
},
});
if (!city) throw new NotFoundException('开城城市不存在');
return serializeBigInt(city);
const cityPartners = await this.partnerCityService.listByCity(id);
return serializeBigInt({
...city,
cityPartners,
storeCount: city._count.stores,
orderCount: city._count.orders,
_count: undefined,
});
}
async create(dto: CreateCityDto) {
@@ -65,31 +95,48 @@ export class AdminCitiesService {
code: dto.code,
name: dto.name,
province: dto.province,
partnerId: dto.partnerId ? BigInt(dto.partnerId) : null,
status: (dto.status ?? 'PENDING') as 'PENDING' | 'ACTIVE' | 'PAUSED',
commissionRule: {
create: {
orderCommissionRate: 0,
redeemCommissionRate: 0.03,
},
},
},
});
return serializeBigInt(city);
}
async update(id: bigint, dto: UpdateCityDto) {
if (dto.maxPartnerCommissionRate !== undefined) {
const maxRate = resolveMaxPartnerCommissionRate(dto.maxPartnerCommissionRate);
const partners = await this.prisma.partnerAccount.findMany({
where: { cityId: id, isPrimary: 1 },
select: {
companyName: true,
orderCommissionRate: true,
redeemCommissionRate: true,
},
});
for (const partner of partners) {
const check = validatePartnerCommissionRates(
Number(partner.orderCommissionRate ?? 0),
Number(partner.redeemCommissionRate ?? 0.03),
maxRate,
);
if (!check.ok) {
throw new BadRequestException(
`无法保存:合伙人「${partner.companyName ?? '—'}${check.message}`,
);
}
}
}
const city = await this.prisma.commonCity.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.province !== undefined ? { province: dto.province } : {}),
...(dto.partnerId !== undefined
? { partnerId: dto.partnerId ? BigInt(dto.partnerId) : null }
: {}),
...(dto.status !== undefined ? { status: dto.status as 'PENDING' | 'ACTIVE' | 'PAUSED' } : {}),
...(dto.localMinQty !== undefined ? { localMinQty: dto.localMinQty } : {}),
...(dto.crossMinQty !== undefined ? { crossMinQty: dto.crossMinQty } : {}),
...(dto.maxPartnerCommissionRate !== undefined
? { maxPartnerCommissionRate: dto.maxPartnerCommissionRate }
: {}),
},
});
return serializeBigInt(city);
@@ -0,0 +1,83 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { CityWarehouseService } from '../city-scope/city-warehouse.service';
import { CreateCityWarehouseDto, UpdateCityWarehouseDto } from './dto/admin-mutate.dto';
import { AdminCityWarehousesQueryDto } from './dto/admin-query.dto';
import type { WarehouseManagerType, WarehouseStatus } from '@prisma/client';
@Controller('admin/cities/:cityId/warehouses')
@UseGuards(HqAuthGuard)
export class AdminCityWarehousesController {
constructor(private readonly service: CityWarehouseService) {}
@Get()
list(@Param('cityId') cityId: string) {
return this.service.listByCity(BigInt(cityId));
}
@Post()
@HqOperation({
action: HqOperationAction.WAREHOUSE_CREATE,
refType: 'WAREHOUSE',
refIdField: 'id',
includeBody: true,
})
create(@Param('cityId') cityId: string, @Body() dto: CreateCityWarehouseDto) {
return this.service.create(BigInt(cityId), {
name: dto.name,
address: dto.address,
contactName: dto.contactName,
contactPhone: dto.contactPhone,
managerType: dto.managerType as WarehouseManagerType,
partnerAccountId: dto.partnerAccountId ? BigInt(dto.partnerAccountId) : undefined,
status: dto.status as WarehouseStatus | undefined,
});
}
}
@Controller('admin/city-warehouses')
@UseGuards(HqAuthGuard)
export class AdminCityWarehouseMutationsController {
constructor(private readonly service: CityWarehouseService) {}
@Get()
listAll(@Query() query: AdminCityWarehousesQueryDto) {
return this.service.listAll(query);
}
@Put(':id')
@HqOperation({
action: HqOperationAction.WAREHOUSE_UPDATE,
refType: 'WAREHOUSE',
refIdParam: 'id',
includeBody: true,
})
update(@Param('id') id: string, @Body() dto: UpdateCityWarehouseDto) {
return this.service.update(BigInt(id), {
name: dto.name,
address: dto.address,
contactName: dto.contactName,
contactPhone: dto.contactPhone,
managerType: dto.managerType as WarehouseManagerType | undefined,
partnerAccountId:
dto.partnerAccountId === null
? undefined
: dto.partnerAccountId
? BigInt(dto.partnerAccountId)
: undefined,
status: dto.status as WarehouseStatus | undefined,
});
}
@Delete(':id')
@HqOperation({
action: HqOperationAction.WAREHOUSE_DELETE,
refType: 'WAREHOUSE',
refIdParam: 'id',
})
remove(@Param('id') id: string) {
return this.service.remove(BigInt(id));
}
}
@@ -38,7 +38,7 @@ export class AdminDashboardService {
_count: { status: true },
}),
this.prisma.store.count(),
this.prisma.partner.count(),
this.prisma.partnerAccount.count({ where: { isPrimary: 1 } }),
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
this.prisma.orderDelivery.count(),
this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
@@ -15,12 +15,12 @@ export class AdminPartnerLogsService {
async list(query: AdminPartnerLogsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const partnerIds = await this.resolvePartnerIds(query);
if (partnerIds && partnerIds.length === 0) {
const partnerAccountIds = await this.resolvePartnerAccountIds(query);
if (partnerAccountIds && partnerAccountIds.length === 0) {
return { items: [], total: 0, page, pageSize };
}
const where = this.buildWhere(query, partnerIds);
const where = this.buildWhere(query, partnerAccountIds);
const [rows, total] = await Promise.all([
this.prisma.logPartnerAnalytics.findMany({
where,
@@ -44,10 +44,10 @@ export class AdminPartnerLogsService {
private buildWhere(
query: AdminPartnerLogsQueryDto,
partnerIds?: bigint[],
partnerAccountIds?: bigint[],
): Prisma.LogPartnerAnalyticsWhereInput {
const where: Prisma.LogPartnerAnalyticsWhereInput = {};
if (partnerIds) where.partnerId = { in: partnerIds };
if (partnerAccountIds) where.partnerAccountId = { in: partnerAccountIds };
if (query.partnerAccountId) where.partnerAccountId = BigInt(query.partnerAccountId);
const categoryEvents = query.eventName
? [query.eventName]
@@ -64,40 +64,23 @@ export class AdminPartnerLogsService {
return where;
}
private async resolvePartnerIds(query: AdminPartnerLogsQueryDto): Promise<bigint[] | undefined> {
private async resolvePartnerAccountIds(
query: AdminPartnerLogsQueryDto,
): Promise<bigint[] | undefined> {
if (query.partnerAccountId) return [BigInt(query.partnerAccountId)];
if (query.partnerId) return [BigInt(query.partnerId)];
const partnerWhere: Prisma.PartnerWhereInput = {};
if (query.companyName) partnerWhere.companyName = { contains: query.companyName };
const accountWhere: Prisma.PartnerAccountWhereInput = { isPrimary: 1 };
if (query.companyName) accountWhere.companyName = { contains: query.companyName };
if (query.phone) accountWhere.phone = { contains: query.phone };
if (query.partnerAccountId || query.phone) {
const accountWhere: Prisma.PartnerAccountWhereInput = {};
if (query.partnerAccountId) accountWhere.id = BigInt(query.partnerAccountId);
if (query.phone) accountWhere.phone = { contains: query.phone };
if (query.companyName || query.phone) {
const accounts = await this.prisma.partnerAccount.findMany({
where: accountWhere,
select: { partnerId: true },
take: 100,
});
if (accounts.length === 0) return [];
const ids = [...new Set(accounts.map((a) => a.partnerId))];
if (query.companyName) {
const partners = await this.prisma.partner.findMany({
where: { id: { in: ids }, ...partnerWhere },
select: { id: true },
});
return partners.map((p) => p.id);
}
return ids;
}
if (query.companyName) {
const partners = await this.prisma.partner.findMany({
where: partnerWhere,
select: { id: true },
take: 100,
});
return partners.map((p) => p.id);
return accounts.map((a) => a.id);
}
return undefined;
@@ -106,8 +89,7 @@ export class AdminPartnerLogsService {
private async enrichRows(
rows: Array<{
id: bigint;
partnerAccountId: bigint | null;
partnerId: bigint;
partnerAccountId: bigint;
eventName: string;
clientApp: string | null;
refType: string | null;
@@ -116,37 +98,25 @@ export class AdminPartnerLogsService {
createdAt: Date;
}>,
) {
const partnerIds = [...new Set(rows.map((r) => r.partnerId))];
const accountIds = [...new Set(rows.map((r) => r.partnerAccountId).filter((id): id is bigint => id != null))];
const accountIds = [...new Set(rows.map((r) => r.partnerAccountId))];
const accounts = accountIds.length
? await this.prisma.partnerAccount.findMany({
where: { id: { in: accountIds } },
select: { id: true, name: true, phone: true, companyName: true },
})
: [];
const [partners, accounts] = await Promise.all([
partnerIds.length
? this.prisma.partner.findMany({
where: { id: { in: partnerIds } },
select: { id: true, companyName: true },
})
: Promise.resolve([]),
accountIds.length
? this.prisma.partnerAccount.findMany({
where: { id: { in: accountIds } },
select: { id: true, name: true, phone: true },
})
: Promise.resolve([]),
]);
const partnerMap = new Map(partners.map((p) => [p.id.toString(), p] as const));
const accountMap = new Map(accounts.map((a) => [a.id.toString(), a] as const));
return rows.map((row) => {
const partner = partnerMap.get(row.partnerId.toString());
const account = row.partnerAccountId ? accountMap.get(row.partnerAccountId.toString()) : undefined;
const account = accountMap.get(row.partnerAccountId.toString());
return {
id: row.id.toString(),
partnerId: row.partnerId.toString(),
partnerAccountId: row.partnerAccountId?.toString() ?? null,
partnerId: row.partnerAccountId.toString(),
partnerAccountId: row.partnerAccountId.toString(),
accountName: account?.name ?? null,
accountPhone: account?.phone ?? null,
companyName: partner?.companyName ?? null,
companyName: account?.companyName ?? null,
category: resolvePartnerLogCategory(row.eventName),
eventName: row.eventName,
clientApp: row.clientApp,
@@ -1,7 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Prisma, CityPartnerScopeType, CityPartnerStatus } from '@prisma/client';
import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service';
import type { AdminPartnerAccountsQueryDto, AdminPartnersQueryDto } from './dto/admin-query.dto';
import type {
CreatePartnerAccountDto,
@@ -10,77 +12,36 @@ import type {
UpdatePartnerDto,
} from './dto/admin-mutate.dto';
const PRIMARY_WHERE = { isPrimary: 1 } as const;
function assertPartnerCommissionRates(
city: { maxPartnerCommissionRate: Prisma.Decimal | number | null },
orderCommissionRate: number,
redeemCommissionRate: number,
) {
const maxRate = resolveMaxPartnerCommissionRate(
city.maxPartnerCommissionRate != null ? Number(city.maxPartnerCommissionRate) : null,
);
const check = validatePartnerCommissionRates(orderCommissionRate, redeemCommissionRate, maxRate);
if (!check.ok) throw new BadRequestException(check.message);
}
@Injectable()
export class AdminPartnersService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
) {}
async listPartners(query: AdminPartnersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerWhereInput = {};
const where: Prisma.PartnerAccountWhereInput = { ...PRIMARY_WHERE };
if (query.companyName) where.companyName = { contains: query.companyName };
if (query.contactPhone) where.contactPhone = { contains: query.contactPhone };
const [items, total] = await Promise.all([
this.prisma.partner.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
_count: { select: { stores: true, accounts: true, cities: true } },
},
}),
this.prisma.partner.count({ where }),
]);
return serializeBigInt({
items: items.map((p) => ({
...p,
storeCount: p._count.stores,
accountCount: p._count.accounts,
cityCount: p._count.cities,
_count: undefined,
})),
total,
page,
pageSize,
});
}
async detailPartner(id: bigint) {
const partner = await this.prisma.partner.findUnique({
where: { id },
include: {
cities: { select: { id: true, code: true, name: true, status: true } },
accounts: { select: { id: true, phone: true, name: true, isPrimary: true, status: true } },
stores: { select: { id: true, name: true, status: true }, take: 10, orderBy: { createdAt: 'desc' } },
_count: { select: { stores: true, accounts: true } },
},
});
if (!partner) throw new NotFoundException('开城合伙人不存在');
return serializeBigInt(partner);
}
async createPartner(dto: CreatePartnerDto) {
const partner = await this.prisma.partner.create({ data: dto });
return serializeBigInt(partner);
}
async updatePartner(id: bigint, dto: UpdatePartnerDto) {
const partner = await this.prisma.partner.update({ where: { id }, data: dto });
return serializeBigInt(partner);
}
async listPartnerAccounts(query: AdminPartnerAccountsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerAccountWhereInput = {};
if (query.phone) where.phone = { contains: query.phone };
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
if (query.isPrimary === '0' || query.isPrimary === '1') {
where.isPrimary = Number(query.isPrimary);
}
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.id = BigInt(query.partnerId);
const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
@@ -89,8 +50,257 @@ export class AdminPartnersService {
skip: (page - 1) * pageSize,
take: pageSize,
include: {
partner: { select: { id: true, companyName: true } },
parent: { select: { id: true, name: true, phone: true } },
city: { select: { id: true, code: true, name: true, status: true, maxPartnerCommissionRate: true } },
managedWarehouse: { select: { id: true, name: true } },
children: {
select: {
id: true,
phone: true,
name: true,
staffRole: true,
permissions: true,
status: true,
},
orderBy: { createdAt: 'asc' },
},
_count: { select: { stores: true, children: true } },
},
}),
this.prisma.partnerAccount.count({ where }),
]);
return serializeBigInt({
items: items.map((p) => ({
id: p.id.toString(),
companyName: p.companyName,
contactPhone: p.contactPhone,
phone: p.phone,
name: p.name,
cityId: p.cityId?.toString() ?? null,
cityName: p.city?.name ?? null,
maxPartnerCommissionRate:
p.city?.maxPartnerCommissionRate != null ? Number(p.city.maxPartnerCommissionRate) : null,
scopeType: p.scopeType,
orderCommissionRate: Number(p.orderCommissionRate ?? 0),
redeemCommissionRate: Number(p.redeemCommissionRate ?? 0.03),
bindingStatus: p.bindingStatus,
managedWarehouseId: p.managedWarehouseId?.toString() ?? null,
managedWarehouseName: p.managedWarehouse?.name ?? null,
storeCount: p._count.stores,
accountCount: p._count.children + 1,
children: p.children.map((c) => ({
id: c.id.toString(),
phone: c.phone,
name: c.name,
staffRole: c.staffRole,
permissions: c.permissions,
status: c.status,
})),
createdAt: p.createdAt,
})),
total,
page,
pageSize,
});
}
async detailPartner(id: bigint) {
const account = await this.prisma.partnerAccount.findFirst({
where: { id, ...PRIMARY_WHERE },
include: {
city: { select: { id: true, code: true, name: true, status: true, maxPartnerCommissionRate: true } },
managedWarehouse: { select: { id: true, name: true } },
children: {
where: { isPrimary: 0 },
select: {
id: true,
phone: true,
name: true,
staffRole: true,
permissions: true,
status: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
},
stores: { select: { id: true, name: true, status: true }, take: 10, orderBy: { createdAt: 'desc' } },
_count: { select: { stores: true, children: true } },
},
});
if (!account) throw new NotFoundException('开城合伙人不存在');
return serializeBigInt({
...this.partnerCityService.toDto({
...account,
city: account.city,
}),
contactPhone: account.contactPhone,
address: account.address,
bankAccountName: account.bankAccountName,
bankAccountNo: account.bankAccountNo,
bankBranch: account.bankBranch,
managedWarehouseName: account.managedWarehouse?.name ?? null,
accountCount: account._count.children + 1,
maxPartnerCommissionRate:
account.city?.maxPartnerCommissionRate != null
? Number(account.city.maxPartnerCommissionRate)
: null,
children: account.children.map((c) => ({
id: c.id.toString(),
phone: c.phone,
name: c.name,
staffRole: c.staffRole,
permissions: c.permissions,
status: c.status,
createdAt: c.createdAt,
})),
});
}
async createPartner(dto: CreatePartnerDto) {
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的登录手机号');
}
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (phoneTaken) throw new BadRequestException('该手机号已被使用');
const cityId = BigInt(dto.cityId);
const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } });
if (!city) throw new NotFoundException('开城城市不存在');
await this.partnerCityService.validatePrimaryBinding(cityId, {
scopeType: dto.scopeType as CityPartnerScopeType,
districtCodes: dto.districtCodes,
});
const orderCommissionRate = dto.orderCommissionRate ?? 0;
const redeemCommissionRate = dto.redeemCommissionRate ?? 0.03;
assertPartnerCommissionRates(city, orderCommissionRate, redeemCommissionRate);
const account = await this.prisma.partnerAccount.create({
data: {
phone,
name: dto.name.trim(),
isPrimary: 1,
staffRole: 'PARTNER',
status: 'ACTIVE',
cityId,
scopeType: dto.scopeType as CityPartnerScopeType,
districtCodes:
dto.scopeType === 'DISTRICT' ? (dto.districtCodes ?? []) : Prisma.JsonNull,
orderCommissionRate: orderCommissionRate,
redeemCommissionRate: redeemCommissionRate,
bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus,
companyName: dto.companyName.trim(),
address: dto.address.trim(),
contactPhone: dto.contactPhone?.trim() ?? phone,
contractNo: dto.contractNo,
bankAccountName: dto.bankAccountName,
bankAccountNo: dto.bankAccountNo,
bankBranch: dto.bankBranch,
weeklyStoreTarget: dto.weeklyStoreTarget ?? 20,
},
include: { city: { select: { id: true, code: true, name: true } } },
});
return serializeBigInt(this.partnerCityService.toDto(account));
}
async updatePartner(id: bigint, dto: UpdatePartnerDto) {
const existing = await this.prisma.partnerAccount.findFirst({
where: { id, ...PRIMARY_WHERE },
});
if (!existing) throw new NotFoundException('开城合伙人不存在');
const city = await this.prisma.commonCity.findUniqueOrThrow({ where: { id: existing.cityId! } });
const cityId = existing.cityId!;
const scopeType = (dto.scopeType ?? existing.scopeType) as CityPartnerScopeType;
const districtCodes =
scopeType === 'DISTRICT'
? dto.districtCodes ?? this.partnerCityService.parseDistrictCodes(existing.districtCodes)
: null;
await this.partnerCityService.validatePrimaryBinding(
cityId,
{
partnerAccountId: id.toString(),
scopeType,
districtCodes: districtCodes ?? undefined,
},
id,
);
if (dto.phone !== undefined) {
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的登录手机号');
}
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (phoneTaken && phoneTaken.id !== id) {
throw new BadRequestException('该手机号已被使用');
}
}
const orderCommissionRate =
dto.orderCommissionRate !== undefined
? dto.orderCommissionRate
: Number(existing.orderCommissionRate ?? 0);
const redeemCommissionRate =
dto.redeemCommissionRate !== undefined
? dto.redeemCommissionRate
: Number(existing.redeemCommissionRate ?? 0.03);
if (dto.orderCommissionRate !== undefined || dto.redeemCommissionRate !== undefined) {
assertPartnerCommissionRates(city, orderCommissionRate, redeemCommissionRate);
}
const account = await this.prisma.partnerAccount.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}),
...(dto.companyName !== undefined ? { companyName: dto.companyName.trim() } : {}),
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
...(dto.contactPhone !== undefined ? { contactPhone: dto.contactPhone.trim() } : {}),
...(dto.scopeType !== undefined ? { scopeType: dto.scopeType as CityPartnerScopeType } : {}),
...(dto.scopeType !== undefined || dto.districtCodes !== undefined
? {
districtCodes:
scopeType === 'DISTRICT'
? ((districtCodes ?? []) as Prisma.InputJsonValue)
: Prisma.JsonNull,
}
: {}),
...(dto.orderCommissionRate !== undefined ? { orderCommissionRate: dto.orderCommissionRate } : {}),
...(dto.redeemCommissionRate !== undefined ? { redeemCommissionRate: dto.redeemCommissionRate } : {}),
...(dto.bindingStatus !== undefined ? { bindingStatus: dto.bindingStatus as CityPartnerStatus } : {}),
...(dto.contractNo !== undefined ? { contractNo: dto.contractNo } : {}),
...(dto.bankAccountName !== undefined ? { bankAccountName: dto.bankAccountName } : {}),
...(dto.bankAccountNo !== undefined ? { bankAccountNo: dto.bankAccountNo } : {}),
...(dto.bankBranch !== undefined ? { bankBranch: dto.bankBranch } : {}),
...(dto.weeklyStoreTarget !== undefined ? { weeklyStoreTarget: dto.weeklyStoreTarget } : {}),
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
},
include: { city: { select: { id: true, code: true, name: true } } },
});
return serializeBigInt(this.partnerCityService.toDto(account));
}
async listPartnerAccounts(query: AdminPartnerAccountsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerAccountWhereInput = { isPrimary: 0 };
if (query.phone) where.phone = { contains: query.phone };
if (query.partnerId) where.parentAccountId = BigInt(query.partnerId);
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
parent: { select: { id: true, name: true, phone: true, companyName: true } },
},
}),
this.prisma.partnerAccount.count({ where }),
@@ -98,14 +308,14 @@ export class AdminPartnersService {
return serializeBigInt({ items, total, page, pageSize });
}
async listPartnerAccountTree(partnerId?: bigint) {
const where: Prisma.PartnerAccountWhereInput = {};
if (partnerId) where.partnerId = partnerId;
async listPartnerAccountTree(primaryAccountId?: bigint) {
const where: Prisma.PartnerAccountWhereInput = primaryAccountId
? { OR: [{ id: primaryAccountId }, { parentAccountId: primaryAccountId }] }
: { isPrimary: 1 };
const accounts = await this.prisma.partnerAccount.findMany({
where,
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
include: { partner: { select: { id: true, companyName: true } } },
});
type TreeNode = (typeof accounts)[number] & { children: TreeNode[] };
@@ -122,7 +332,7 @@ export class AdminPartnersService {
const parent = nodeMap.get(account.parentAccountId.toString());
if (parent) parent.children.push(node);
else roots.push(node);
} else {
} else if (account.isPrimary === 1) {
roots.push(node);
}
}
@@ -134,8 +344,9 @@ export class AdminPartnersService {
status: node.status,
isPrimary: node.isPrimary,
staffRole: node.staffRole,
permissions: node.permissions,
parentAccountId: node.parentAccountId,
partner: node.partner,
companyName: node.companyName,
createdAt: node.createdAt,
lastLoginAt: node.lastLoginAt,
children: node.children.length ? node.children.map(mapNode) : undefined,
@@ -148,27 +359,21 @@ export class AdminPartnersService {
const account = await this.prisma.partnerAccount.findUnique({
where: { id },
include: {
partner: {
select: {
id: true,
companyName: true,
contactPhone: true,
address: true,
},
},
parent: { select: { id: true, name: true, phone: true } },
parent: { select: { id: true, name: true, phone: true, companyName: true } },
},
});
if (!account) throw new NotFoundException('开城合伙人账号不存在');
if (!account) throw new NotFoundException('合伙人账号不存在');
const primary = await this.partnerCityService.resolvePrimaryAccount(id);
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
const [bills, orders] = await Promise.all([
this.prisma.partnerBill.findMany({
where: { partnerId: account.partnerId },
where: { partnerAccountId: primary.id },
orderBy: { createdAt: 'desc' },
take: 50,
}),
this.prisma.order.findMany({
where: { city: { partnerId: account.partnerId } },
where: orderWhere,
orderBy: { createdAt: 'desc' },
take: 50,
select: {
@@ -182,10 +387,14 @@ export class AdminPartnersService {
}),
]);
return serializeBigInt({ ...account, bills, orders });
return serializeBigInt({ ...account, primaryAccountId: primary.id, bills, orders });
}
async createPartnerAccount(dto: CreatePartnerAccountDto) {
if (!dto.parentAccountId) {
throw new BadRequestException('请指定主账号 parentAccountId 创建子账号');
}
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的手机号码');
@@ -193,53 +402,40 @@ export class AdminPartnersService {
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (phoneTaken) throw new BadRequestException('该手机号已被使用');
if (dto.parentAccountId) {
const parent = await this.prisma.partnerAccount.findUnique({
where: { id: BigInt(dto.parentAccountId) },
});
if (!parent) throw new BadRequestException('主账号不存在');
if (parent.isPrimary !== 1) throw new BadRequestException('仅可向主账号添加子账号');
if (dto.partnerId && dto.partnerId !== parent.partnerId.toString()) {
throw new BadRequestException('开城合伙人与主账号不匹配');
}
const account = await this.prisma.partnerAccount.create({
data: {
partnerId: parent.partnerId,
phone,
name: dto.name.trim(),
staffRole: (dto.staffRole ?? 'INTERNAL') as 'PARTNER' | 'INTERNAL' | 'PROMOTER',
isPrimary: 0,
parentAccountId: parent.id,
status: 'ACTIVE',
},
include: { partner: { select: { id: true, companyName: true } } },
});
return serializeBigInt(account);
const parent = await this.prisma.partnerAccount.findUnique({
where: { id: BigInt(dto.parentAccountId) },
});
if (!parent) throw new BadRequestException('主账号不存在');
if (parent.isPrimary !== 1) {
throw new BadRequestException('仅可向主账号添加子账号,不支持多级子账号');
}
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId!) } });
if (!partner) throw new BadRequestException('开城合伙人不存在');
const account = await this.prisma.partnerAccount.create({
data: {
partnerId: partner.id,
phone,
name: dto.name.trim(),
staffRole: dto.staffRole ? (dto.staffRole as 'PARTNER' | 'INTERNAL' | 'PROMOTER') : undefined,
staffRole: (dto.staffRole ?? 'INTERNAL') as 'PARTNER' | 'INTERNAL' | 'PROMOTER',
permissions: dto.permissions ?? undefined,
isPrimary: 0,
parentAccountId: parent.id,
status: 'ACTIVE',
},
include: { partner: { select: { id: true, companyName: true } } },
});
return serializeBigInt(account);
}
async updatePartnerAccount(id: bigint, dto: UpdatePartnerAccountDto) {
const existing = await this.prisma.partnerAccount.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('开城合伙人账号不存在');
if (!existing) throw new NotFoundException('合伙人账号不存在');
if (existing.isPrimary === 1) {
throw new BadRequestException('请通过开城合伙人接口编辑主账号');
}
const data: Prisma.PartnerAccountUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.status !== undefined) data.status = dto.status as 'ACTIVE' | 'DISABLED';
if (dto.staffRole !== undefined) data.staffRole = dto.staffRole as 'PARTNER' | 'INTERNAL' | 'PROMOTER';
if (dto.permissions !== undefined) data.permissions = dto.permissions;
if (dto.phone !== undefined) {
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
@@ -258,7 +454,7 @@ export class AdminPartnersService {
async deletePartnerSubAccount(id: bigint) {
const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
if (!account) throw new NotFoundException('开城合伙人账号不存在');
if (!account) throw new NotFoundException('合伙人账号不存在');
if (!account.parentAccountId) {
throw new BadRequestException('仅可删除子账号');
}
@@ -40,7 +40,7 @@ export class AdminRedeemService {
where: { id },
include: {
user: true,
store: { include: { partner: { select: { id: true, companyName: true } } } },
store: { include: { partnerAccount: { select: { id: true, companyName: true } } } },
coupon: true,
payout: true,
},
@@ -4,6 +4,7 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
import { PartnerCityService } from '../city-scope/partner-city.service';
import type {
CreateStoreAccountDto,
CreateStoreDto,
@@ -16,7 +17,10 @@ import type {
@Injectable()
export class AdminStoresService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
) {}
async listStores(query: AdminStoresQueryDto) {
const page = query.page ?? 1;
@@ -25,7 +29,7 @@ export class AdminStoresService {
if (query.name) where.name = { contains: query.name };
if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals'];
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
if (query.phone) where.phone = { contains: query.phone };
const [items, total] = await Promise.all([
@@ -36,7 +40,7 @@ export class AdminStoresService {
take: pageSize,
include: {
cityRef: { select: { id: true, name: true, code: true } },
partner: { select: { id: true, companyName: true } },
partnerAccount: { select: { id: true, companyName: true } },
account: { select: { id: true, phone: true, name: true, status: true } },
coverResource: { select: { id: true, url: true } },
},
@@ -56,7 +60,7 @@ export class AdminStoresService {
where: { id },
include: {
cityRef: true,
partner: true,
partnerAccount: true,
category: true,
account: true,
coverResource: true,
@@ -123,6 +127,7 @@ export class AdminStoresService {
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
...(dto.address !== undefined ? { address: dto.address } : {}),
...(dto.district !== undefined ? { district: dto.district } : {}),
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
},
});
@@ -162,18 +167,22 @@ export class AdminStoresService {
});
if (existingAccount) throw new BadRequestException('该手机号已绑定门店');
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId) } });
if (!partner) throw new BadRequestException('开城合伙人不存在');
const partnerAccountId = BigInt(dto.partnerAccountId);
const partnerAccount = await this.prisma.partnerAccount.findUnique({
where: { id: partnerAccountId },
});
if (!partnerAccount || partnerAccount.isPrimary !== 1) {
throw new BadRequestException('开城合伙人不存在');
}
const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } });
if (!city) throw new BadRequestException('开城城市不存在');
if (city.partnerId && city.partnerId !== partner.id) {
throw new BadRequestException('开城城市与合伙人不匹配');
}
await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id);
const store = await this.prisma.store.create({
data: {
cityId: city.id,
partnerId: partner.id,
partnerAccountId,
settlementRate: dto.settlementRate ?? 0.6,
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
name: dto.name,
phone: normalizedPhone,
@@ -359,7 +368,7 @@ export class AdminStoresService {
async detailStoreAccount(id: bigint) {
const account = await this.prisma.storeAccount.findUnique({
where: { id },
include: { store: { include: { cityRef: true, partner: true } } },
include: { store: { include: { cityRef: true, partnerAccount: true } } },
});
if (!account) throw new NotFoundException('门店账号不存在');
return serializeBigInt(account);
@@ -4,6 +4,7 @@ import { BenefitService } from '../benefit/benefit.service';
import { TradeService } from '../trade/trade.service';
import { TicketService } from '../common/ticket.service';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service';
import type { TicketListQueryDto } from '../common/dto/common-query.dto';
@Injectable()
@@ -13,6 +14,7 @@ export class AdminTicketsService {
private readonly ticketService: TicketService,
private readonly tradeService: TradeService,
private readonly benefitService: BenefitService,
private readonly partnerCityService: PartnerCityService,
) {}
list(query: TicketListQueryDto) {
@@ -82,15 +84,9 @@ export class AdminTicketsService {
}
async listPartnerReshipments(partnerAccountId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const cities = await this.prisma.commonCity.findMany({
where: { partnerId: account.partnerId },
select: { id: true },
});
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(partnerAccountId);
const orders = await this.prisma.order.findMany({
where: { cityId: { in: cities.map((c) => c.id) } },
where: orderWhere,
select: { id: true },
});
const orderIds = orders.map((o) => o.id);
@@ -213,11 +213,23 @@ export class AdminWechatBindingsService {
const accounts = await this.prisma.partnerAccount.findMany({
where,
include: { partner: { select: { id: true, companyName: true } } },
select: {
id: true,
phone: true,
name: true,
wxOpenId: true,
wxUnionId: true,
lastLoginAt: true,
status: true,
isPrimary: true,
parentAccountId: true,
companyName: true,
},
});
for (const a of accounts) {
if (!a.wxOpenId) continue;
const refId = a.isPrimary === 1 ? a.id : (a.parentAccountId ?? a.id);
rows.push({
actorType: 'PARTNER',
actorId: a.id,
@@ -225,8 +237,8 @@ export class AdminWechatBindingsService {
name: a.name,
wxOpenId: a.wxOpenId,
wxUnionId: a.wxUnionId,
refId: a.partnerId,
refLabel: a.partner.companyName,
refId,
refLabel: a.companyName,
lastLoginAt: a.lastLoginAt,
status: a.status,
});
@@ -9,6 +9,7 @@ import {
IsOptional,
IsString,
Min,
Max,
MinLength,
ValidateIf,
} from 'class-validator';
@@ -22,7 +23,7 @@ export class UpdateStoreStatusDto {
export class CreateStoreDto {
@IsString()
@IsNotEmpty()
partnerId: string;
partnerAccountId: string;
@IsString()
@IsNotEmpty()
@@ -88,6 +89,11 @@ export class CreateStoreDto {
@IsOptional()
@IsString()
contractUrl?: string;
@IsOptional()
@IsNumber()
@Min(0)
settlementRate?: number;
}
export class UpdateStoreDto {
@@ -114,6 +120,11 @@ export class UpdateStoreDto {
@IsOptional()
@IsString()
district?: string;
@IsOptional()
@IsNumber()
@Min(0)
settlementRate?: number;
}
export class CreateStoreAccountDto {
@@ -145,6 +156,18 @@ export class UpdateStoreAccountDto {
}
export class CreatePartnerDto {
@IsString()
@IsNotEmpty()
cityId: string;
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsNotEmpty()
companyName: string;
@@ -153,9 +176,40 @@ export class CreatePartnerDto {
@IsNotEmpty()
address: string;
@IsOptional()
@IsString()
@IsNotEmpty()
contactPhone: string;
contactPhone?: string;
@IsString()
@IsIn(['CITY_WIDE', 'DISTRICT'])
scopeType: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
districtCodes?: string[];
@IsOptional()
@IsNumber()
@Min(0)
orderCommissionRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
redeemCommissionRate?: number;
@IsOptional()
@IsIn(['ACTIVE', 'PAUSED'])
bindingStatus?: string;
@IsOptional()
@IsString()
managedWarehouseId?: string;
@IsOptional()
@IsString()
contractNo?: string;
@IsOptional()
@IsString()
@@ -168,9 +222,21 @@ export class CreatePartnerDto {
@IsOptional()
@IsString()
bankBranch?: string;
@IsOptional()
@IsNumber()
weeklyStoreTarget?: number;
}
export class UpdatePartnerDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
companyName?: string;
@@ -183,6 +249,38 @@ export class UpdatePartnerDto {
@IsString()
contactPhone?: string;
@IsOptional()
@IsIn(['CITY_WIDE', 'DISTRICT'])
scopeType?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
districtCodes?: string[];
@IsOptional()
@IsNumber()
@Min(0)
orderCommissionRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
redeemCommissionRate?: number;
@IsOptional()
@IsIn(['ACTIVE', 'PAUSED'])
bindingStatus?: string;
@IsOptional()
@ValidateIf((_, v) => v !== null)
@IsString()
managedWarehouseId?: string | null;
@IsOptional()
@IsString()
contractNo?: string;
@IsOptional()
@IsString()
bankAccountName?: string;
@@ -194,6 +292,14 @@ export class UpdatePartnerDto {
@IsOptional()
@IsString()
bankBranch?: string;
@IsOptional()
@IsNumber()
weeklyStoreTarget?: number;
@IsOptional()
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
}
export class UpdatePartnerAccountDto {
@@ -208,13 +314,21 @@ export class UpdatePartnerAccountDto {
@IsOptional()
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
@IsOptional()
@IsIn(['PARTNER', 'INTERNAL', 'PROMOTER'])
staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
}
export class CreatePartnerAccountDto {
@ValidateIf((o: CreatePartnerAccountDto) => !o.parentAccountId)
@IsString()
@IsNotEmpty()
partnerId?: string;
parentAccountId: string;
@IsString()
@IsNotEmpty()
@@ -228,10 +342,10 @@ export class CreatePartnerAccountDto {
@IsIn(['PARTNER', 'INTERNAL', 'PROMOTER'])
staffRole?: string;
/** 主账号 ID;传入则创建子账号 */
@IsOptional()
@IsString()
parentAccountId?: string;
@IsArray()
@IsString({ each: true })
permissions?: string[];
}
export class CreateCityDto {
@@ -247,10 +361,6 @@ export class CreateCityDto {
@IsNotEmpty()
province: string;
@IsOptional()
@IsString()
partnerId?: string;
@IsOptional()
@IsIn(['PENDING', 'ACTIVE', 'PAUSED'])
status?: string;
@@ -265,10 +375,6 @@ export class UpdateCityDto {
@IsString()
province?: string;
@IsOptional()
@IsString()
partnerId?: string;
@IsOptional()
@IsIn(['PENDING', 'ACTIVE', 'PAUSED'])
status?: string;
@@ -278,6 +384,127 @@ export class UpdateCityDto {
@IsOptional()
crossMinQty?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
maxPartnerCommissionRate?: number;
}
export class BindCityPartnerDto {
@IsString()
@IsNotEmpty()
partnerId: string;
@IsString()
@IsIn(['CITY_WIDE', 'DISTRICT'])
scopeType: 'CITY_WIDE' | 'DISTRICT';
@IsOptional()
@IsArray()
@IsString({ each: true })
districtCodes?: string[];
@IsOptional()
@IsNumber()
@Min(0)
orderCommissionRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
redeemCommissionRate?: number;
@IsOptional()
@IsIn(['ACTIVE', 'PAUSED'])
status?: 'ACTIVE' | 'PAUSED';
}
export class UpdateCityPartnerDto {
@IsOptional()
@IsIn(['CITY_WIDE', 'DISTRICT'])
scopeType?: 'CITY_WIDE' | 'DISTRICT';
@IsOptional()
@IsArray()
@IsString({ each: true })
districtCodes?: string[];
@IsOptional()
@IsNumber()
@Min(0)
orderCommissionRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
redeemCommissionRate?: number;
@IsOptional()
@IsIn(['ACTIVE', 'PAUSED'])
status?: 'ACTIVE' | 'PAUSED';
}
export class CreateCityWarehouseDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsNotEmpty()
address: string;
@IsString()
@IsNotEmpty()
contactName: string;
@IsString()
@IsNotEmpty()
contactPhone: string;
@IsString()
@IsIn(['HQ', 'PARTNER'])
managerType: 'HQ' | 'PARTNER';
@IsOptional()
@IsString()
partnerAccountId?: string;
@IsOptional()
@IsIn(['ACTIVE', 'PAUSED'])
status?: 'ACTIVE' | 'PAUSED';
}
export class UpdateCityWarehouseDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
contactName?: string;
@IsOptional()
@IsString()
contactPhone?: string;
@IsOptional()
@IsIn(['HQ', 'PARTNER'])
managerType?: 'HQ' | 'PARTNER';
@IsOptional()
@ValidateIf((_, v) => v !== null)
@IsString()
partnerAccountId?: string | null;
@IsOptional()
@IsIn(['ACTIVE', 'PAUSED'])
status?: 'ACTIVE' | 'PAUSED';
}
export class CreateStoreMediaDto {
@@ -109,6 +109,36 @@ export class AdminPartnersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
contactPhone?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
cityId?: string;
@IsOptional()
@IsString()
partnerId?: string;
}
export class AdminCityWarehousesQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
cityId?: string;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
managerType?: string;
@IsOptional()
@IsString()
status?: string;
}
export class AdminPartnerAccountsQueryDto extends PaginationQueryDto {
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { IamModule } from '../iam/iam.module';
import { TradeModule } from '../trade/trade.module';
import { AdminDashboardController } from './admin-dashboard.controller';
@@ -12,6 +13,7 @@ import { AdminStoresService } from './admin-stores.service';
import { AdminPartnersController, AdminPartnerAccountsController } from './admin-partners.controller';
import { AdminPartnersService } from './admin-partners.service';
import { AdminCitiesController } from './admin-cities.controller';
import { AdminCityWarehousesController, AdminCityWarehouseMutationsController } from './admin-city-warehouses.controller';
import { AdminCitiesService } from './admin-cities.service';
import { AdminBenefitCouponsController, AdminBenefitLedgersController } from './admin-benefit.controller';
import { AdminBenefitService } from './admin-benefit.service';
@@ -50,7 +52,7 @@ import { AdminHqPermissionsController } from './admin-hq-permissions.controller'
import { AdminHqPermissionsService } from './admin-hq-permissions.service';
@Module({
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
imports: [CityScopeModule, IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
controllers: [
AdminDashboardController,
AdminUsersController,
@@ -61,6 +63,8 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
AdminPartnersController,
AdminPartnerAccountsController,
AdminCitiesController,
AdminCityWarehousesController,
AdminCityWarehouseMutationsController,
AdminBenefitCouponsController,
AdminBenefitLedgersController,
AdminRedeemRecordsController,
@@ -104,5 +108,6 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
AdminHqPermissionsService,
SuperAdminGuard,
],
exports: [CityScopeModule],
})
export class OpsModule {}
@@ -236,10 +236,7 @@ export class RedeemService {
}
const amount = tokenAmount;
const cityRule = await this.prisma.commonCityCommissionRule.findUnique({
where: { cityId: account.store.cityId },
});
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
const settlementRate = Number(account.store.settlementRate);
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
let record;
@@ -152,15 +152,22 @@ export class PartnerMeController {
async me(@CurrentUser() user: AuthUser) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: user.actorId },
include: { partner: true },
});
let primary = account;
if (account.isPrimary !== 1 && account.parentAccountId) {
primary = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: account.parentAccountId },
});
}
return {
id: account.id.toString(),
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
staffRole: account.staffRole ?? undefined,
companyName: account.partner.companyName,
permissions: Array.isArray(account.permissions) ? account.permissions : undefined,
primaryAccountId: primary.id.toString(),
companyName: primary.companyName ?? undefined,
hasWechat: !!account.wxOpenId,
};
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { AnalyticsModule } from '../analytics/analytics.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { SettlementService } from './settlement.service';
import {
AdminPartnerBillController,
@@ -11,7 +12,7 @@ import {
} from './settlement.controller';
@Module({
imports: [IamModule, AnalyticsModule],
imports: [IamModule, AnalyticsModule, CityScopeModule],
controllers: [
SettlementController,
PartnerMeController,
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
function generateBillNo() {
return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
@@ -13,6 +14,7 @@ export class SettlementService {
constructor(
private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
) {}
async createStorePayout(
@@ -152,16 +154,13 @@ export class SettlementService {
return results;
}
async listPartnerBills(partnerAccountId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
async listPartnerBills(partnerAccountId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const bills = await this.prisma.partnerBill.findMany({
where: { partnerId: account.partnerId },
where: { partnerAccountId: primary.id },
orderBy: { createdAt: 'desc' },
});
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
partnerAccountId: primary.id,
eventName: 'partner_bill_view',
extraJson: { count: bills.length },
});
@@ -178,7 +177,7 @@ export class SettlementService {
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerBillWhereInput = {};
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
const [items, total] = await Promise.all([
this.prisma.partnerBill.findMany({
@@ -186,7 +185,7 @@ export class SettlementService {
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { partner: { select: { companyName: true } } },
include: { partnerAccount: { select: { companyName: true } } },
}),
this.prisma.partnerBill.count({ where }),
]);
@@ -196,20 +195,21 @@ export class SettlementService {
async getAdminPartnerBill(id: bigint) {
const bill = await this.prisma.partnerBill.findUnique({
where: { id },
include: { partner: true },
include: { partnerAccount: true },
});
if (!bill) throw new NotFoundException('账单不存在');
return serializeBigInt(bill);
}
async generatePartnerBill(body: { partnerId: string; year: number; month: number }) {
const partnerId = BigInt(body.partnerId);
const partnerAccountId = BigInt(body.partnerId);
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const periodStart = new Date(body.year, body.month - 1, 1);
const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999);
const existing = await this.prisma.partnerBill.findFirst({
where: {
partnerId,
partnerAccountId: primary.id,
periodStart,
status: { not: 'DRAFT' },
},
@@ -218,31 +218,33 @@ export class SettlementService {
throw new BadRequestException('该月账单已确认,不可重复生成');
}
const cities = await this.prisma.commonCity.findMany({
where: { partnerId },
include: { commissionRule: true },
});
const cityIds = cities.map((c) => c.id);
const defaultOrderRate = cities[0]?.commissionRule?.orderCommissionRate
? Number(cities[0].commissionRule.orderCommissionRate)
: 0;
const defaultRedeemRate = cities[0]?.commissionRule?.redeemCommissionRate
? Number(cities[0].commissionRule.redeemCommissionRate)
: 0.03;
if (!primary.cityId) {
throw new BadRequestException('合伙人未绑定开城城市');
}
const orderCommissionRate = Number(primary.orderCommissionRate ?? 0);
const redeemCommissionRate = Number(primary.redeemCommissionRate ?? 0.03);
const orders = await this.prisma.order.findMany({
where: {
cityId: { in: cityIds },
cityId: primary.cityId,
payStatus: 'PAID',
paidAt: { gte: periodStart, lte: periodEnd },
},
});
const orderCommission = orders.reduce(
(sum, o) => sum + Number(o.payAmount) * defaultOrderRate,
0,
);
const orderCommission = orders.reduce((sum, o) => {
if (o.partnerAccountIdAtPay) {
if (o.partnerAccountIdAtPay !== primary.id) return sum;
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
return sum + Number(o.payAmount) * rate;
}
return sum + Number(o.payAmount) * orderCommissionRate;
}, 0);
const stores = await this.prisma.store.findMany({ where: { partnerId }, select: { id: true } });
const stores = await this.prisma.store.findMany({
where: { partnerAccountId: primary.id },
select: { id: true },
});
const storeIds = stores.map((s) => s.id);
const redeems = await this.prisma.redeemRecord.findMany({
where: {
@@ -251,14 +253,14 @@ export class SettlementService {
},
});
const redeemCommission = redeems.reduce(
(sum, r) => sum + Number(r.amount) * defaultRedeemRate,
(sum, r) => sum + Number(r.amount) * redeemCommissionRate,
0,
);
const totalAmount = Math.round((orderCommission + redeemCommission) * 100) / 100;
const draft = await this.prisma.partnerBill.findFirst({
where: { partnerId, periodStart, status: 'DRAFT' },
where: { partnerAccountId: primary.id, periodStart, status: 'DRAFT' },
});
const bill = draft
@@ -269,7 +271,7 @@ export class SettlementService {
: await this.prisma.partnerBill.create({
data: {
billNo: generateBillNo(),
partnerId,
partnerAccountId: primary.id,
periodStart,
periodEnd,
orderCommission,
@@ -310,12 +312,12 @@ export class SettlementService {
async exportPartnerBills(query: { partnerId?: string; status?: string }) {
const where: Prisma.PartnerBillWhereInput = {};
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
const bills = await this.prisma.partnerBill.findMany({
where,
include: { partner: { select: { companyName: true } } },
include: { partnerAccount: { select: { companyName: true } } },
orderBy: { createdAt: 'desc' },
});
@@ -323,7 +325,7 @@ export class SettlementService {
const rows = bills.map((b) =>
[
b.billNo,
b.partner.companyName,
b.partnerAccount.companyName,
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
Number(b.orderCommission),
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { RedeemModule } from '../redeem/redeem.module';
import { AnalyticsModule } from '../analytics/analytics.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { StoreService } from './store.service';
import {
PartnerDashboardController,
@@ -13,7 +14,7 @@ import {
} from './store.controller';
@Module({
imports: [IamModule, AnalyticsModule, forwardRef(() => RedeemModule)],
imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)],
controllers: [
PublicStoreController,
PartnerStoreController,
@@ -11,6 +11,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { mapStoreCompat } from '../../common/compat/v31-compat';
import { parseBigIntParam } from '../../common/parse-bigint';
import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
@Injectable()
export class StoreService {
@@ -19,6 +20,7 @@ export class StoreService {
constructor(
private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
) {}
async listOpenStores(cityCode?: string) {
@@ -48,9 +50,15 @@ export class StoreService {
return serializeBigInt(mapStoreCompat({ ...store, media }));
}
private async resolvePartnerScope(actorAccountId: bigint) {
const account = await this.getPartnerAccount(actorAccountId);
const primaryId = await this.getPartnerPrimaryId(actorAccountId);
return { account, primaryId };
}
async partnerListStores(partnerAccountId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId);
const where: { partnerId: bigint; id?: { in: bigint[] } } = { partnerId: account.partnerId };
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
const where: { partnerAccountId: bigint; id?: { in: bigint[] } } = { partnerAccountId: primaryId };
if (this.isSubAccount(account)) {
const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId);
if (storeIds.length === 0) return [];
@@ -65,9 +73,9 @@ export class StoreService {
}
async partnerGetStore(partnerAccountId: bigint, storeId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId);
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerId: account.partnerId },
where: { id: storeId, partnerAccountId: primaryId },
include: { category: true, coverResource: true },
});
if (!store) throw new NotFoundException('门店不存在');
@@ -88,10 +96,11 @@ export class StoreService {
}
async partnerListCities(partnerAccountId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId);
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
const cityWhere = await this.partnerCityService.buildPartnerCityWhere(primaryId);
const cities = await this.prisma.commonCity.findMany({
where: { partnerId: account.partnerId },
select: { id: true, name: true, code: true, province: true, partnerId: true },
where: cityWhere,
select: { id: true, name: true, code: true, province: true },
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(cities);
@@ -115,11 +124,11 @@ export class StoreService {
}
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
const account = await this.getPartnerAccount(partnerAccountId);
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
const normalizedPhone = String(body.phone).trim();
await this.assertStorePhoneAvailable(normalizedPhone);
const city = await this.resolvePartnerCity(account.partnerId, body.cityId);
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
const envPhotoUrls = Array.isArray(body.envPhotoUrls)
? body.envPhotoUrls.map((u) => String(u).trim()).filter(Boolean)
@@ -133,7 +142,7 @@ export class StoreService {
const store = await this.prisma.store.create({
data: {
cityId: city.id,
partnerId: account.partnerId,
partnerAccountId: primaryId,
categoryId: body.categoryId ? parseBigIntParam(body.categoryId, '分类ID') : null,
name: String(body.name),
phone: normalizedPhone,
@@ -220,7 +229,7 @@ export class StoreService {
});
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
partnerAccountId: primaryId,
eventName: 'partner_store_create',
refType: 'STORE',
refId: store.id,
@@ -240,10 +249,10 @@ export class StoreService {
storeId: bigint,
status: 'OPEN' | 'PAUSED' | 'CLOSED',
) {
const account = await this.getPartnerAccount(partnerAccountId);
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerId: account.partnerId },
where: { id: storeId, partnerAccountId: primaryId },
});
if (!store) throw new NotFoundException('门店不存在');
if (store.status === 'CLOSED') {
@@ -259,7 +268,7 @@ export class StoreService {
include: { coverResource: true },
});
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
partnerAccountId: primaryId,
eventName: 'partner_store_status_change',
refType: 'STORE',
refId: storeId,
@@ -276,10 +285,10 @@ export class StoreService {
storeId: bigint,
body: Record<string, unknown>,
) {
const account = await this.getPartnerAccount(partnerAccountId);
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerId: account.partnerId },
where: { id: storeId, partnerAccountId: primaryId },
});
if (!store) throw new NotFoundException('门店不存在');
if (store.status === 'CLOSED') {
@@ -343,20 +352,20 @@ export class StoreService {
}
async partnerDashboard(partnerAccountId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId);
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account);
const partnerStoreIds = await this.prisma.store.findMany({
where: { partnerId: account.partnerId },
where: { partnerAccountId: primaryId },
select: { id: true },
});
const storeIds = partnerStoreIds.map((s) => s.id);
const [storeCount, orderCount, recentStores, pendingAuditCount] = await Promise.all([
this.prisma.store.count({ where: { partnerId: account.partnerId } }),
this.prisma.store.count({ where: { partnerAccountId: primaryId } }),
this.prisma.order.count({
where: { city: { partnerId: account.partnerId } },
where: await this.partnerCityService.buildPartnerOrderWhere(primaryId),
}),
this.prisma.store.findMany({
where: { partnerId: account.partnerId },
where: { partnerAccountId: primaryId },
select: { id: true, name: true, status: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: 10,
@@ -375,18 +384,17 @@ export class StoreService {
return {
storeCount,
orderCount,
companyName: account.partner.companyName,
companyName: account.companyName ?? '',
recentStores: serializeBigInt(recentStores),
pendingAuditCount,
};
}
async partnerLeaderboard(partnerAccountId: bigint, period: PartnerLeaderboardPeriod = 'total') {
const account = await this.getPartnerAccount(partnerAccountId);
this.assertPrimaryAccount(account);
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
const accounts = await this.prisma.partnerAccount.findMany({
where: { partnerId: account.partnerId },
where: { OR: [{ id: primaryId }, { parentAccountId: primaryId }] },
orderBy: { id: 'asc' },
});
@@ -457,10 +465,9 @@ export class StoreService {
return { period, list, self };
}
async partnerWeeklyReport(partnerAccountId: bigint, startDate?: string) {
const account = await this.getPartnerAccount(partnerAccountId);
async partnerWeeklyReport(actorAccountId: bigint, startDate?: string) {
const { account, primaryId } = await this.resolvePartnerScope(actorAccountId);
this.assertPrimaryAccount(account);
const partnerId = account.partnerId;
const currentWeekStart = this.startOfWeekMonday(new Date());
const periodStart =
@@ -472,16 +479,16 @@ export class StoreService {
const prevPeriodEnd = periodStart;
const newStoreTarget =
account.partner.weeklyStoreTarget ??
account.weeklyStoreTarget ??
Number(process.env.PARTNER_WEEKLY_STORE_TARGET ?? 20);
const orderWhere = {
city: { partnerId },
...(await this.partnerCityService.buildPartnerOrderWhere(primaryId)),
payStatus: 'PAID' as const,
paidAt: { gte: periodStart, lt: periodEnd },
};
const prevOrderWhere = {
city: { partnerId },
...(await this.partnerCityService.buildPartnerOrderWhere(primaryId)),
payStatus: 'PAID' as const,
paidAt: { gte: prevPeriodStart, lt: prevPeriodEnd },
};
@@ -498,16 +505,16 @@ export class StoreService {
] = await Promise.all([
this.prisma.order.aggregate({ where: orderWhere, _sum: { payAmount: true } }),
this.prisma.order.count({ where: orderWhere }),
this.prisma.store.count({ where: { partnerId } }),
this.prisma.store.count({ where: { partnerAccountId: primaryId } }),
this.prisma.store.count({
where: { partnerId, createdAt: { gte: periodStart, lt: periodEnd } },
where: { partnerAccountId: primaryId, createdAt: { gte: periodStart, lt: periodEnd } },
}),
this.prisma.order.aggregate({ where: prevOrderWhere, _sum: { payAmount: true } }),
this.prisma.redeemRecord.groupBy({
by: ['storeId'],
where: {
createdAt: { gte: periodStart, lt: periodEnd },
store: { partnerId },
store: { partnerAccountId: primaryId },
},
_sum: { amount: true },
orderBy: { _sum: { amount: 'desc' } },
@@ -516,7 +523,7 @@ export class StoreService {
this.prisma.redeemRecord.findMany({
where: {
createdAt: { gte: periodStart, lt: periodEnd },
store: { partnerId },
store: { partnerAccountId: primaryId },
},
select: { storeId: true },
distinct: ['storeId'],
@@ -677,22 +684,29 @@ export class StoreService {
return ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][index] ?? '';
}
private async getPartnerPrimaryId(actorAccountId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(actorAccountId);
return primary.id;
}
private async getPartnerAccount(partnerAccountId: bigint) {
return this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
include: { partner: true },
});
}
private async resolvePartnerCity(partnerId: bigint, cityId: unknown) {
private async resolvePartnerCity(actorAccountId: bigint, cityId: unknown) {
const primaryId = await this.getPartnerPrimaryId(actorAccountId);
if (cityId) {
const city = await this.prisma.commonCity.findFirst({
where: { id: parseBigIntParam(cityId, '城市ID'), partnerId },
});
const id = parseBigIntParam(cityId, '城市ID');
await this.partnerCityService.assertPartnerAccountBoundToCity(primaryId, id);
const city = await this.prisma.commonCity.findUnique({ where: { id } });
if (!city) throw new BadRequestException('所选地区未匹配到开城城市');
return city;
}
const city = await this.prisma.commonCity.findFirst({ where: { partnerId } });
const cityIds = await this.partnerCityService.listCityIdsForPartnerAccount(primaryId);
if (!cityIds.length) throw new BadRequestException('合伙人未绑定开城');
const city = await this.prisma.commonCity.findFirst({ where: { id: { in: cityIds } } });
if (!city) throw new BadRequestException('合伙人未绑定开城');
return city;
}
@@ -5,11 +5,12 @@ import { IamModule } from '../iam/iam.module';
import { BenefitModule } from '../benefit/benefit.module';
import { CatalogModule } from '../catalog/catalog.module';
import { CommonModule } from '../common/common.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { TradeController, PartnerOrderController, PartnerReshipmentController } from './trade.controller';
import { TradeService } from './trade.service';
@Module({
imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, forwardRef(() => BenefitModule), CommonModule],
imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, CityScopeModule, forwardRef(() => BenefitModule), CommonModule],
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
providers: [TradeService],
exports: [TradeService],
@@ -17,6 +17,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { AnalyticsService } from '../analytics/analytics.service';
import { CatalogService } from '../catalog/catalog.service';
import { BenefitService } from '../benefit/benefit.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { TicketService } from '../common/ticket.service';
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
import { IPayProvider } from '../../integrations/pay/pay.interface';
@@ -39,6 +40,7 @@ export class TradeService {
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
) {}
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
@@ -220,6 +222,10 @@ export class TradeService {
const { externalNo } = payResult;
const now = new Date();
const paySnapshot = await this.partnerCityService.resolveForOrder(
order.cityId,
order.receiverDistrict,
);
await this.prisma.$transaction(async (tx) => {
await tx.order.update({
@@ -229,6 +235,8 @@ export class TradeService {
payStatus: 'PAID',
paidAt: now,
payExternalNo: externalNo,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
},
});
await tx.logThirdParty.create({
@@ -300,6 +308,10 @@ export class TradeService {
}
const now = new Date();
const paySnapshot = await this.partnerCityService.resolveForOrder(
order.cityId,
order.receiverDistrict,
);
await this.prisma.$transaction(async (tx) => {
const current = await tx.order.findUnique({ where: { id: order.id } });
if (!current || current.payStatus === 'PAID') return;
@@ -311,6 +323,8 @@ export class TradeService {
payStatus: 'PAID',
paidAt: now,
payExternalNo: params.transactionId,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
},
});
await tx.logThirdParty.create({
@@ -450,15 +464,10 @@ export class TradeService {
}
async listPartnerReshipments(partnerAccountId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const cities = await this.prisma.commonCity.findMany({
where: { partnerId: account.partnerId },
select: { id: true },
});
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
const orders = await this.prisma.order.findMany({
where: { cityId: { in: cities.map((c) => c.id) } },
where: orderWhere,
select: { id: true },
});
const tickets = await this.prisma.commonTicket.findMany({
@@ -473,12 +482,8 @@ export class TradeService {
}
async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const cities = await this.prisma.commonCity.findMany({ where: { partnerId: account.partnerId } });
const cityIds = cities.map((c) => c.id);
const where = { cityId: { in: cityIds } };
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const where = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
const [list, total] = await Promise.all([
this.prisma.order.findMany({
where,
@@ -493,12 +498,10 @@ export class TradeService {
}
async getPartnerOrder(partnerAccountId: bigint, orderId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const cities = await this.prisma.commonCity.findMany({ where: { partnerId: account.partnerId } });
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
const order = await this.prisma.order.findFirst({
where: { id: orderId, cityId: { in: cities.map((c) => c.id) } },
where: { id: orderId, ...partnerOrderWhere },
include: { delivery: true, user: true, imageResource: true },
});
if (!order) throw new NotFoundException('订单不存在');
@@ -510,13 +513,12 @@ export class TradeService {
}
async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
const order = await this.prisma.order.findFirst({
where: {
id: orderId,
city: { partnerId: account.partnerId },
...partnerOrderWhere,
},
include: { delivery: true },
});
@@ -524,7 +526,7 @@ export class TradeService {
await this.applyStatusTransition(order.id, order.status, targetStatus);
if (targetStatus === 'SHIPPING') {
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
partnerAccountId: primary.id,
eventName: 'partner_order_ship',
refType: 'ORDER',
refId: orderId,
@@ -532,7 +534,7 @@ export class TradeService {
});
}
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
partnerAccountId: primary.id,
eventName: 'partner_delivery_advance',
refType: 'ORDER',
refId: orderId,