feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理
订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -201,6 +201,7 @@ CREATE TABLE common_city (
|
||||
UNIQUE KEY uk_common_city_code (code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='开城配置';
|
||||
|
||||
DROP TABLE IF EXISTS partner_user_note;
|
||||
DROP TABLE IF EXISTS partner_account;
|
||||
CREATE TABLE partner_account (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
@@ -231,11 +232,14 @@ CREATE TABLE partner_account (
|
||||
bank_branch VARCHAR(128) DEFAULT NULL,
|
||||
weekly_store_target INT DEFAULT 20,
|
||||
managed_warehouse_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
assoc_qrcode_id VARCHAR(64) DEFAULT NULL,
|
||||
assoc_qrcode_resource_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),
|
||||
UNIQUE KEY uk_partner_account_managed_warehouse (managed_warehouse_id),
|
||||
UNIQUE KEY uk_partner_account_assoc_qrcode (assoc_qrcode_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),
|
||||
@@ -287,6 +291,26 @@ CREATE TABLE partner_bill (
|
||||
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账单';
|
||||
|
||||
DROP TABLE IF EXISTS partner_bill_item;
|
||||
CREATE TABLE partner_bill_item (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
partner_bill_id BIGINT UNSIGNED NOT NULL,
|
||||
kind VARCHAR(16) NOT NULL COMMENT 'ORDER|REDEEM',
|
||||
ref_id BIGINT UNSIGNED NOT NULL,
|
||||
ref_no VARCHAR(32) NOT NULL,
|
||||
title VARCHAR(128) DEFAULT NULL,
|
||||
extra VARCHAR(128) DEFAULT NULL,
|
||||
base_amount DECIMAL(10,2) NOT NULL,
|
||||
rate DECIMAL(5,4) NOT NULL,
|
||||
commission DECIMAL(10,2) NOT NULL,
|
||||
occurred_at DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_partner_bill_item_kind_ref (kind, ref_id),
|
||||
KEY idx_partner_bill_item_bill (partner_bill_id),
|
||||
CONSTRAINT fk_partner_bill_item_bill FOREIGN KEY (partner_bill_id) REFERENCES partner_bill(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合伙人账单明细';
|
||||
|
||||
-- ===================== HQ =============================
|
||||
|
||||
DROP TABLE IF EXISTS hq_account;
|
||||
@@ -307,6 +331,7 @@ CREATE TABLE hq_account (
|
||||
|
||||
-- ===================== USER =============================
|
||||
|
||||
DROP TABLE IF EXISTS partner_user_note;
|
||||
DROP TABLE IF EXISTS user_user;
|
||||
CREATE TABLE user_user (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
@@ -320,10 +345,12 @@ CREATE TABLE user_user (
|
||||
nickname VARCHAR(64) DEFAULT NULL,
|
||||
avatar_resource_id BIGINT UNSIGNED DEFAULT NULL COMMENT '头像 common_resource.id',
|
||||
status TINYINT NOT NULL DEFAULT 1,
|
||||
source_type VARCHAR(32) NOT NULL DEFAULT 'ORGANIC' COMMENT 'ORGANIC|PROMO_CODE|SHARE_LINK|FRIEND_REFERRAL|OFFLINE_EVENT|OTHER',
|
||||
source_type VARCHAR(32) NOT NULL DEFAULT 'ORGANIC' COMMENT 'ORGANIC|PROMO_CODE|SHARE_LINK|FRIEND_REFERRAL|OFFLINE_EVENT|PARTNER_PROXY|PARTNER_ASSOC|OTHER',
|
||||
source_ref_id BIGINT UNSIGNED DEFAULT NULL COMMENT 'promo_code_id 或 referrer_user_id',
|
||||
source_label VARCHAR(128) DEFAULT NULL COMMENT '渠道名称快照',
|
||||
referrer_user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '好友推荐人 user_user.id',
|
||||
assoc_partner_account_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
assoc_bound_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),
|
||||
PRIMARY KEY (id),
|
||||
@@ -332,10 +359,26 @@ CREATE TABLE user_user (
|
||||
UNIQUE KEY uk_user_user_device_key (device_key),
|
||||
KEY idx_user_user_source (source_type, source_ref_id),
|
||||
KEY idx_user_user_referrer (referrer_user_id),
|
||||
KEY idx_user_user_assoc_partner (assoc_partner_account_id),
|
||||
KEY idx_user_user_merged (merged_into_user_id),
|
||||
KEY idx_user_user_wx_open (wx_open_id)
|
||||
KEY idx_user_user_wx_open (wx_open_id),
|
||||
CONSTRAINT fk_user_user_assoc_partner FOREIGN KEY (assoc_partner_account_id) REFERENCES partner_account(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='C端用户';
|
||||
|
||||
CREATE TABLE partner_user_note (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
partner_account_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
remark VARCHAR(128) NOT 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_user_note_partner_user (partner_account_id, user_id),
|
||||
KEY idx_partner_user_note_user (user_id),
|
||||
CONSTRAINT fk_partner_user_note_partner FOREIGN KEY (partner_account_id) REFERENCES partner_account(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_partner_user_note_user FOREIGN KEY (user_id) REFERENCES user_user(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主合伙人对关联用户的备注';
|
||||
|
||||
DROP TABLE IF EXISTS user_address;
|
||||
CREATE TABLE user_address (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
@@ -579,6 +622,9 @@ CREATE TABLE user_store_rating (
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
service_score TINYINT NOT NULL,
|
||||
env_score TINYINT NOT NULL,
|
||||
comment VARCHAR(200) NULL,
|
||||
tags JSON NULL,
|
||||
image_urls JSON NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_user_store_rating_redeem (redeem_record_id),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
-- v4.0.1:合伙人关联码 + 用户关联 + 账单明细
|
||||
|
||||
ALTER TABLE `partner_account`
|
||||
ADD COLUMN `assoc_qrcode_id` VARCHAR(64) DEFAULT NULL AFTER `managed_warehouse_id`,
|
||||
ADD COLUMN `assoc_qrcode_resource_id` BIGINT UNSIGNED DEFAULT NULL AFTER `assoc_qrcode_id`,
|
||||
ADD UNIQUE KEY `uk_partner_account_assoc_qrcode` (`assoc_qrcode_id`);
|
||||
|
||||
ALTER TABLE `user_user`
|
||||
ADD COLUMN `assoc_partner_account_id` BIGINT UNSIGNED DEFAULT NULL AFTER `referrer_user_id`,
|
||||
ADD COLUMN `assoc_bound_at` DATETIME(3) DEFAULT NULL AFTER `assoc_partner_account_id`,
|
||||
ADD KEY `idx_user_user_assoc_partner` (`assoc_partner_account_id`);
|
||||
|
||||
ALTER TABLE `user_user`
|
||||
ADD CONSTRAINT `fk_user_user_assoc_partner`
|
||||
FOREIGN KEY (`assoc_partner_account_id`) REFERENCES `partner_account`(`id`) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `partner_bill_item` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`partner_bill_id` BIGINT UNSIGNED NOT NULL,
|
||||
`kind` VARCHAR(16) NOT NULL COMMENT 'ORDER|REDEEM',
|
||||
`ref_id` BIGINT UNSIGNED NOT NULL,
|
||||
`ref_no` VARCHAR(32) NOT NULL,
|
||||
`title` VARCHAR(128) DEFAULT NULL,
|
||||
`extra` VARCHAR(128) DEFAULT NULL,
|
||||
`base_amount` DECIMAL(10,2) NOT NULL,
|
||||
`rate` DECIMAL(5,4) NOT NULL,
|
||||
`commission` DECIMAL(10,2) NOT NULL,
|
||||
`occurred_at` DATETIME(3) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_partner_bill_item_kind_ref` (`kind`, `ref_id`),
|
||||
KEY `idx_partner_bill_item_bill` (`partner_bill_id`),
|
||||
CONSTRAINT `fk_partner_bill_item_bill` FOREIGN KEY (`partner_bill_id`) REFERENCES `partner_bill`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合伙人账单明细';
|
||||
@@ -0,0 +1,16 @@
|
||||
-- v4.0.1:合伙人可见用户备注(与 HQ hq_remark 隔离)
|
||||
-- Review 后再在生产执行。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `partner_user_note` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`partner_account_id` BIGINT UNSIGNED NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`remark` VARCHAR(128) NOT 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_user_note_partner_user` (`partner_account_id`, `user_id`),
|
||||
KEY `idx_partner_user_note_user` (`user_id`),
|
||||
CONSTRAINT `fk_partner_user_note_partner` FOREIGN KEY (`partner_account_id`) REFERENCES `partner_account`(`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_partner_user_note_user` FOREIGN KEY (`user_id`) REFERENCES `user_user`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主合伙人对关联用户的备注';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- v3.5.12:核销评价补充说明 / 标签 / 图片
|
||||
ALTER TABLE `user_store_rating`
|
||||
ADD COLUMN `comment` VARCHAR(200) NULL AFTER `env_score`,
|
||||
ADD COLUMN `tags` JSON NULL AFTER `comment`,
|
||||
ADD COLUMN `image_urls` JSON NULL AFTER `tags`;
|
||||
@@ -339,9 +339,15 @@ enum UserSourceType {
|
||||
FRIEND_REFERRAL
|
||||
OFFLINE_EVENT
|
||||
PARTNER_PROXY
|
||||
PARTNER_ASSOC
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum PartnerBillItemKind {
|
||||
ORDER
|
||||
REDEEM
|
||||
}
|
||||
|
||||
enum OrderType {
|
||||
NORMAL
|
||||
RESHIPMENT
|
||||
@@ -636,6 +642,7 @@ model CommonResource {
|
||||
|
||||
productCovers CommonProductItem[] @relation("ProductCover")
|
||||
promoQrcodes CommonPromoCode[] @relation("PromoQrcode")
|
||||
partnerAssocQrcodes PartnerAccount[] @relation("PartnerAssocQrcode")
|
||||
userAvatars User[] @relation("UserAvatar")
|
||||
storeCovers Store[] @relation("StoreCover")
|
||||
orderImages Order[] @relation("OrderProductImage")
|
||||
@@ -1195,6 +1202,8 @@ model PartnerAccount {
|
||||
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
|
||||
assocQrcodeId String? @unique @map("assoc_qrcode_id") @db.VarChar(64)
|
||||
assocQrcodeResourceId BigInt? @map("assoc_qrcode_resource_id") @db.UnsignedBigInt
|
||||
/// 测试合伙人账号
|
||||
isTest Boolean @default(false) @map("is_test")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
@@ -1207,6 +1216,9 @@ model PartnerAccount {
|
||||
children PartnerAccount[] @relation("PartnerAccountHierarchy")
|
||||
stores Store[]
|
||||
bills PartnerBill[]
|
||||
assocUsers User[] @relation("UserPartnerAssoc")
|
||||
userNotes PartnerUserNote[]
|
||||
assocQrcodeResource CommonResource? @relation("PartnerAssocQrcode", fields: [assocQrcodeResourceId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([cityId, scopeType])
|
||||
@@index([cityId, isPrimary])
|
||||
@@ -1217,6 +1229,23 @@ model PartnerAccount {
|
||||
@@map("partner_account")
|
||||
}
|
||||
|
||||
/// 主合伙人对关联用户的备注;与 HQ hqRemark 隔离,换绑不跟随
|
||||
model PartnerUserNote {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
|
||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||
remark String @db.VarChar(128)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
partner PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([partnerAccountId, userId])
|
||||
@@index([userId])
|
||||
@@map("partner_user_note")
|
||||
}
|
||||
|
||||
model PartnerBill {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
billNo String @unique @map("bill_no") @db.VarChar(32)
|
||||
@@ -1235,12 +1264,34 @@ model PartnerBill {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
|
||||
items PartnerBillItem[]
|
||||
|
||||
@@index([partnerAccountId, status])
|
||||
@@unique([partnerAccountId, periodStart])
|
||||
@@map("partner_bill")
|
||||
}
|
||||
|
||||
model PartnerBillItem {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
partnerBillId BigInt @map("partner_bill_id") @db.UnsignedBigInt
|
||||
kind PartnerBillItemKind
|
||||
refId BigInt @map("ref_id") @db.UnsignedBigInt
|
||||
refNo String @map("ref_no") @db.VarChar(32)
|
||||
title String? @db.VarChar(128)
|
||||
extra String? @db.VarChar(128)
|
||||
baseAmount Decimal @map("base_amount") @db.Decimal(10, 2)
|
||||
rate Decimal @db.Decimal(5, 4)
|
||||
commission Decimal @db.Decimal(10, 2)
|
||||
occurredAt DateTime @map("occurred_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
partnerBill PartnerBill @relation(fields: [partnerBillId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([kind, refId])
|
||||
@@index([partnerBillId])
|
||||
@@map("partner_bill_item")
|
||||
}
|
||||
|
||||
// ─── HQ ───────────────────────────────────────────────
|
||||
|
||||
model HqAccount {
|
||||
@@ -1322,6 +1373,8 @@ model User {
|
||||
sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
|
||||
sourceLabel String? @map("source_label") @db.VarChar(128)
|
||||
referrerUserId BigInt? @map("referrer_user_id") @db.UnsignedBigInt
|
||||
assocPartnerAccountId BigInt? @map("assoc_partner_account_id") @db.UnsignedBigInt
|
||||
assocBoundAt DateTime? @map("assoc_bound_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
@@ -1329,6 +1382,8 @@ model User {
|
||||
mergedFrom User[] @relation("UserMerge")
|
||||
referrer User? @relation("UserReferrer", fields: [referrerUserId], references: [id], onDelete: SetNull)
|
||||
referrers User[] @relation("UserReferrer")
|
||||
assocPartner PartnerAccount? @relation("UserPartnerAssoc", fields: [assocPartnerAccountId], references: [id], onDelete: SetNull)
|
||||
partnerNotes PartnerUserNote[]
|
||||
avatar CommonResource? @relation("UserAvatar", fields: [avatarResourceId], references: [id], onDelete: SetNull)
|
||||
addresses UserAddress[]
|
||||
cityPreference UserCityPreference?
|
||||
@@ -1343,6 +1398,7 @@ model User {
|
||||
|
||||
@@index([sourceType, sourceRefId])
|
||||
@@index([referrerUserId])
|
||||
@@index([assocPartnerAccountId])
|
||||
@@index([mergedIntoUserId])
|
||||
@@index([wxOpenId])
|
||||
@@index([isTest])
|
||||
@@ -1877,6 +1933,9 @@ model StoreRating {
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
serviceScore Int @map("service_score") @db.TinyInt
|
||||
envScore Int @map("env_score") @db.TinyInt
|
||||
comment String? @db.VarChar(200)
|
||||
tags Json?
|
||||
imageUrls Json? @map("image_urls")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 上传小程序冷启动静态资源到 OSS(固定 key,不进小程序主包):
|
||||
* static/mini-user/jiuzu-dragon.gif
|
||||
* static/mini-user/jiuzu-dragon-mark.png
|
||||
*
|
||||
* 用法(在 server/dukang-api):
|
||||
* node scripts/upload-mini-user-static.mjs
|
||||
* 使用 server/dukang-api/.env 中的 OSS 凭证。
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve, dirname, extname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const apiRoot = resolve(__dirname, '..');
|
||||
const repoRoot = resolve(apiRoot, '../..');
|
||||
const require = createRequire(resolve(apiRoot, 'package.json'));
|
||||
const OSS = require('ali-oss');
|
||||
|
||||
function loadEnvFile(path) {
|
||||
if (!existsSync(path)) return {};
|
||||
const out = {};
|
||||
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
||||
if (!m) continue;
|
||||
let v = m[2];
|
||||
if (
|
||||
(v.startsWith('"') && v.endsWith('"')) ||
|
||||
(v.startsWith("'") && v.endsWith("'"))
|
||||
) {
|
||||
v = v.slice(1, -1);
|
||||
}
|
||||
out[m[1]] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const env = {
|
||||
...loadEnvFile(resolve(apiRoot, '.env.production')),
|
||||
...loadEnvFile(resolve(apiRoot, '.env.development')),
|
||||
...loadEnvFile(resolve(apiRoot, '.env')),
|
||||
};
|
||||
|
||||
const accessKeyId = process.env.OSS_ACCESS_KEY_ID || env.OSS_ACCESS_KEY_ID || '';
|
||||
const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET || env.OSS_ACCESS_KEY_SECRET || '';
|
||||
const bucket = process.env.OSS_BUCKET || env.OSS_BUCKET || '';
|
||||
const region = process.env.OSS_REGION || env.OSS_REGION || 'oss-cn-beijing';
|
||||
const cdnBase = (process.env.OSS_CDN_BASE || env.OSS_CDN_BASE || '').replace(/\/$/, '');
|
||||
|
||||
if (!accessKeyId || !accessKeySecret || !bucket) {
|
||||
console.error('缺少 OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET / OSS_BUCKET');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mimeByExt = {
|
||||
'.gif': 'image/gif',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
|
||||
const files = [
|
||||
{
|
||||
local:
|
||||
process.env.JIUZU_SPLASH_GIF_LOCAL ||
|
||||
env.JIUZU_SPLASH_GIF_LOCAL ||
|
||||
resolve(repoRoot, 'apps/mini-user/src/assets/jiuzu-dragon.gif'),
|
||||
ossKey: 'static/mini-user/jiuzu-dragon.gif',
|
||||
},
|
||||
{
|
||||
local:
|
||||
process.env.JIUZU_SPLASH_MARK_LOCAL ||
|
||||
env.JIUZU_SPLASH_MARK_LOCAL ||
|
||||
resolve(repoRoot, 'apps/mini-user/src/assets/jiuzu-dragon-mark.png'),
|
||||
ossKey: 'static/mini-user/jiuzu-dragon-mark.png',
|
||||
},
|
||||
];
|
||||
|
||||
const client = new OSS({
|
||||
region,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
bucket,
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
if (!existsSync(file.local)) {
|
||||
console.error('本地文件不存在:', file.local);
|
||||
process.exit(1);
|
||||
}
|
||||
const ext = extname(file.local).toLowerCase();
|
||||
const mime = mimeByExt[ext];
|
||||
if (!mime) {
|
||||
console.error('不支持的文件类型:', file.local);
|
||||
process.exit(1);
|
||||
}
|
||||
const buffer = readFileSync(file.local);
|
||||
await client.put(file.ossKey, buffer, {
|
||||
mime,
|
||||
headers: {
|
||||
'Content-Disposition': 'inline',
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
},
|
||||
});
|
||||
const url = cdnBase
|
||||
? `${cdnBase}/${file.ossKey}`
|
||||
: `https://${bucket}.${region}.aliyuncs.com/${file.ossKey}`;
|
||||
console.log('uploaded:', file.ossKey);
|
||||
console.log('url:', url);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export const HqOperationAction = {
|
||||
HQ_ACCOUNT_UPDATE: 'HQ_ACCOUNT_UPDATE',
|
||||
HQ_PERMISSION_UPDATE: 'HQ_PERMISSION_UPDATE',
|
||||
USER_UPDATE: 'USER_UPDATE',
|
||||
USER_ASSOC_UPDATE: 'USER_ASSOC_UPDATE',
|
||||
USER_DELETE: 'USER_DELETE',
|
||||
USER_BATCH_DELETE: 'USER_BATCH_DELETE',
|
||||
ORDER_SHIP: 'ORDER_SHIP',
|
||||
@@ -144,6 +145,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员',
|
||||
[HqOperationAction.HQ_PERMISSION_UPDATE]: '配置 HQ 权限',
|
||||
[HqOperationAction.USER_UPDATE]: '修改用户备注',
|
||||
[HqOperationAction.USER_ASSOC_UPDATE]: '修改用户关联合伙人',
|
||||
[HqOperationAction.USER_DELETE]: '删除用户',
|
||||
[HqOperationAction.USER_BATCH_DELETE]: '批量删除用户',
|
||||
[HqOperationAction.ORDER_SHIP]: '订单发货',
|
||||
|
||||
@@ -8,13 +8,17 @@ import { Observable, map } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const res = context.switchToHttp().getResponse<{ headersSent?: boolean }>();
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: data ?? null,
|
||||
})),
|
||||
map((data) => {
|
||||
if (res.headersSent) return data;
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: data ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: 'https://xxx.oss-cn-beijing.aliyuncs.com/static/mini-user/',
|
||||
description: '资质公示等静态资源根路径;完整 URL 优先用下方「资质公示图」',
|
||||
description: '资质公示、开场 GIF 等静态资源根路径;完整 URL 优先用下方「资质公示图」',
|
||||
},
|
||||
{
|
||||
key: 'QUALIFICATION_DISCLOSURE_URL',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma, CityPartnerScopeType, CityPartnerStatus } from '@prisma/client';
|
||||
import { resolveOrderCityPartner, validatePartnerCityBinding } from '@dukang/domain';
|
||||
import { Prisma, CityPartnerScopeType } from '@prisma/client';
|
||||
import { validatePartnerCityBinding } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@@ -45,30 +45,50 @@ export class PartnerCityService {
|
||||
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;
|
||||
async snapshotForPartner(partnerAccountId: bigint) {
|
||||
const primary = await this.resolvePrimaryAccount(partnerAccountId);
|
||||
if (primary.bindingStatus === 'PAUSED' || primary.status !== 'ACTIVE') {
|
||||
return { partnerAccountId: null as bigint | null, orderCommissionRate: null as number | null };
|
||||
}
|
||||
return {
|
||||
partnerAccountId: BigInt(ref.partnerAccountId),
|
||||
orderCommissionRate: ref.orderCommissionRate,
|
||||
redeemCommissionRate: ref.redeemCommissionRate,
|
||||
partnerAccountId: primary.id,
|
||||
orderCommissionRate: Number(primary.orderCommissionRate ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async snapshotForUser(userId: bigint) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { assocPartnerAccountId: true },
|
||||
});
|
||||
if (!user?.assocPartnerAccountId) {
|
||||
return { partnerAccountId: null as bigint | null, orderCommissionRate: null as number | null };
|
||||
}
|
||||
return this.snapshotForPartner(user.assocPartnerAccountId);
|
||||
}
|
||||
|
||||
/** 代下单显式选择:本单快照归该合伙人;用户未关联则 first-lock */
|
||||
async applyProxyAssoc(userId: bigint, partnerAccountId: bigint) {
|
||||
const snapshot = await this.snapshotForPartner(partnerAccountId);
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { assocPartnerAccountId: true, sourceType: true },
|
||||
});
|
||||
if (user && !user.assocPartnerAccountId && snapshot.partnerAccountId) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
assocPartnerAccountId: snapshot.partnerAccountId,
|
||||
assocBoundAt: new Date(),
|
||||
...(user.sourceType === 'ORGANIC'
|
||||
? { sourceType: 'PARTNER_ASSOC', sourceRefId: snapshot.partnerAccountId }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async validatePrimaryBinding(
|
||||
cityId: bigint,
|
||||
input: {
|
||||
|
||||
@@ -40,6 +40,7 @@ type OrderFilterInput = Pick<
|
||||
| 'excludeTest'
|
||||
| 'deliveryType'
|
||||
| 'promoCodeId'
|
||||
| 'assocPartnerAccountId'
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -217,6 +218,12 @@ export class AdminOrdersService {
|
||||
if (promoCodeId && /^\d+$/.test(promoCodeId)) {
|
||||
where.promoCodeId = BigInt(promoCodeId);
|
||||
}
|
||||
const assocPartnerAccountId = query.assocPartnerAccountId?.trim();
|
||||
if (assocPartnerAccountId === 'none') {
|
||||
where.partnerAccountIdAtPay = null;
|
||||
} else if (assocPartnerAccountId && /^\d+$/.test(assocPartnerAccountId)) {
|
||||
where.partnerAccountIdAtPay = BigInt(assocPartnerAccountId);
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
@@ -282,6 +289,9 @@ export class AdminOrdersService {
|
||||
hqRemark: true,
|
||||
deviceKey: true,
|
||||
phoneVerifiedAt: true,
|
||||
assocPartnerAccountId: true,
|
||||
assocBoundAt: true,
|
||||
assocPartner: { select: { id: true, name: true, companyName: true, phone: true } },
|
||||
},
|
||||
},
|
||||
delivery: {
|
||||
@@ -328,11 +338,54 @@ export class AdminOrdersService {
|
||||
: { redeemSummary: null, redeemRecords: [] };
|
||||
|
||||
const withFee = this.withDeliveryLogisticsFee(order);
|
||||
const { benefitCoupon: _coupon, ...orderRest } = withFee;
|
||||
const { benefitCoupon: _coupon, user, ...orderRest } = withFee;
|
||||
|
||||
let assocPartnerAtPay: {
|
||||
id: string;
|
||||
name: string;
|
||||
companyName: string | null;
|
||||
phone: string | null;
|
||||
orderCommissionRate: number | null;
|
||||
} | null = null;
|
||||
if (order.partnerAccountIdAtPay) {
|
||||
const partner = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: order.partnerAccountIdAtPay },
|
||||
select: { id: true, name: true, companyName: true, phone: true },
|
||||
});
|
||||
if (partner) {
|
||||
assocPartnerAtPay = {
|
||||
id: partner.id.toString(),
|
||||
name: partner.name,
|
||||
companyName: partner.companyName,
|
||||
phone: partner.phone,
|
||||
orderCommissionRate:
|
||||
order.orderCommissionRateAtPay != null ? Number(order.orderCommissionRateAtPay) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const userAssoc = user?.assocPartner
|
||||
? {
|
||||
id: user.assocPartner.id.toString(),
|
||||
name: user.assocPartner.name,
|
||||
companyName: user.assocPartner.companyName,
|
||||
phone: user.assocPartner.phone,
|
||||
}
|
||||
: null;
|
||||
|
||||
return serializeBigInt(
|
||||
mapOrderCompat({
|
||||
...orderRest,
|
||||
user: user
|
||||
? {
|
||||
...user,
|
||||
assocPartner: userAssoc,
|
||||
assocBoundAt: user.assocBoundAt?.toISOString() ?? null,
|
||||
}
|
||||
: user,
|
||||
assocPartnerAtPay,
|
||||
orderCommissionRateAtPay:
|
||||
order.orderCommissionRateAtPay != null ? Number(order.orderCommissionRateAtPay) : null,
|
||||
statusLogs: mapStatusLogCompat(statusLogs),
|
||||
benefitCoupons: coupon
|
||||
? [
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminPartnersService } from './admin-partners.service';
|
||||
import { PartnerAssocService } from '../store/partner-assoc.service';
|
||||
import { AdminPartnerAccountsQueryDto, AdminPartnersQueryDto } from './dto/admin-query.dto';
|
||||
import {
|
||||
CreatePartnerAccountDto,
|
||||
@@ -14,7 +19,10 @@ import {
|
||||
@Controller('admin/partners')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminPartnersController {
|
||||
constructor(private readonly service: AdminPartnersService) {}
|
||||
constructor(
|
||||
private readonly service: AdminPartnersService,
|
||||
private readonly assoc: PartnerAssocService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminPartnersQueryDto) {
|
||||
@@ -47,6 +55,38 @@ export class AdminPartnersController {
|
||||
update(@Param('id') id: string, @Body() dto: UpdatePartnerDto) {
|
||||
return this.service.updatePartner(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Get(':id/assoc')
|
||||
assocSummary(@Param('id') id: string) {
|
||||
return this.assoc.getSummary(BigInt(id));
|
||||
}
|
||||
|
||||
@Get(':id/assoc/users')
|
||||
assocUsers(
|
||||
@Param('id') id: string,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.assoc.listUsers(BigInt(id), Number(page) || 1, Number(pageSize) || 20);
|
||||
}
|
||||
|
||||
@Post(':id/assoc/qrcode')
|
||||
regenQrcode(@Param('id') id: string) {
|
||||
return this.assoc.ensureQrcode(BigInt(id), true);
|
||||
}
|
||||
|
||||
@Post(':id/assoc/users/:userId/unbind')
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('users_partner_assoc')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.USER_ASSOC_UPDATE,
|
||||
refType: 'USER',
|
||||
refIdParam: 'userId',
|
||||
includeBody: true,
|
||||
})
|
||||
unbindUser(@Param('userId') userId: string) {
|
||||
return this.assoc.unbindUser(BigInt(userId));
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/partner-accounts')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { PartnerAssocService } from '../store/partner-assoc.service';
|
||||
import type { AdminPartnerAccountsQueryDto, AdminPartnersQueryDto } from './dto/admin-query.dto';
|
||||
import type {
|
||||
CreatePartnerAccountDto,
|
||||
@@ -26,11 +27,24 @@ function assertPartnerCommissionRates(
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
}
|
||||
|
||||
function toCommissionDecimal(rate: number): Prisma.Decimal {
|
||||
return new Prisma.Decimal(Number(rate).toFixed(4));
|
||||
}
|
||||
|
||||
/** 选填字符串:undefined 不改;null / 空白写成 null */
|
||||
function trimOptionalText(value: string | null | undefined): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value == null) return null;
|
||||
const t = String(value).trim();
|
||||
return t.length ? t : null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminPartnersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
private readonly partnerAssocService: PartnerAssocService,
|
||||
) {}
|
||||
|
||||
async listPartners(query: AdminPartnersQueryDto) {
|
||||
@@ -64,7 +78,7 @@ export class AdminPartnersService {
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
_count: { select: { stores: true, children: true } },
|
||||
_count: { select: { stores: true, children: true, assocUsers: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.partnerAccount.count({ where }),
|
||||
@@ -88,6 +102,7 @@ export class AdminPartnersService {
|
||||
managedWarehouseId: p.managedWarehouseId?.toString() ?? null,
|
||||
managedWarehouseName: p.managedWarehouse?.name ?? null,
|
||||
storeCount: p._count.stores,
|
||||
assocUserCount: p._count.assocUsers,
|
||||
accountCount: p._count.children + 1,
|
||||
children: p.children.map((c) => ({
|
||||
id: c.id.toString(),
|
||||
@@ -125,7 +140,7 @@ export class AdminPartnersService {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
stores: { select: { id: true, name: true, status: true }, take: 10, orderBy: { createdAt: 'desc' } },
|
||||
_count: { select: { stores: true, children: true } },
|
||||
_count: { select: { stores: true, children: true, assocUsers: true } },
|
||||
},
|
||||
});
|
||||
if (!account) throw new NotFoundException('开城合伙人不存在');
|
||||
@@ -140,6 +155,8 @@ export class AdminPartnersService {
|
||||
bankAccountNo: account.bankAccountNo,
|
||||
bankBranch: account.bankBranch,
|
||||
managedWarehouseName: account.managedWarehouse?.name ?? null,
|
||||
storeCount: account._count.stores,
|
||||
assocUserCount: account._count.assocUsers,
|
||||
accountCount: account._count.children + 1,
|
||||
maxPartnerCommissionRate:
|
||||
account.city?.maxPartnerCommissionRate != null
|
||||
@@ -189,8 +206,8 @@ export class AdminPartnersService {
|
||||
scopeType: dto.scopeType as CityPartnerScopeType,
|
||||
districtCodes:
|
||||
dto.scopeType === 'DISTRICT' ? (dto.districtCodes ?? []) : Prisma.JsonNull,
|
||||
orderCommissionRate: orderCommissionRate,
|
||||
redeemCommissionRate: redeemCommissionRate,
|
||||
orderCommissionRate: toCommissionDecimal(orderCommissionRate),
|
||||
redeemCommissionRate: toCommissionDecimal(redeemCommissionRate),
|
||||
bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus,
|
||||
companyName: dto.companyName?.trim() || null,
|
||||
address: dto.address?.trim() || null,
|
||||
@@ -204,7 +221,13 @@ export class AdminPartnersService {
|
||||
include: { city: { select: { id: true, code: true, name: true } } },
|
||||
});
|
||||
|
||||
return serializeBigInt(this.partnerCityService.toDto(account));
|
||||
const dtoOut = serializeBigInt(this.partnerCityService.toDto(account));
|
||||
try {
|
||||
await this.partnerAssocService.ensureQrcode(account.id);
|
||||
} catch {
|
||||
/* 补码失败不挡创建 */
|
||||
}
|
||||
return dtoOut;
|
||||
}
|
||||
|
||||
async updatePartner(id: bigint, dto: UpdatePartnerDto) {
|
||||
@@ -221,17 +244,26 @@ export class AdminPartnersService {
|
||||
? dto.districtCodes ?? this.partnerCityService.parseDistrictCodes(existing.districtCodes)
|
||||
: null;
|
||||
|
||||
await this.partnerCityService.validatePrimaryBinding(
|
||||
cityId,
|
||||
{
|
||||
partnerAccountId: id.toString(),
|
||||
scopeType,
|
||||
districtCodes: districtCodes ?? undefined,
|
||||
},
|
||||
id,
|
||||
);
|
||||
const existingDistricts = this.partnerCityService.parseDistrictCodes(existing.districtCodes);
|
||||
const nextDistrictsSorted = [...(districtCodes ?? [])].sort();
|
||||
const existingDistrictsSorted = [...(existingDistricts ?? [])].sort();
|
||||
const scopeActuallyChanged =
|
||||
(dto.scopeType !== undefined && dto.scopeType !== existing.scopeType) ||
|
||||
(dto.districtCodes !== undefined &&
|
||||
JSON.stringify(nextDistrictsSorted) !== JSON.stringify(existingDistrictsSorted));
|
||||
if (scopeActuallyChanged) {
|
||||
await this.partnerCityService.validatePrimaryBinding(
|
||||
cityId,
|
||||
{
|
||||
partnerAccountId: id.toString(),
|
||||
scopeType,
|
||||
districtCodes: districtCodes ?? undefined,
|
||||
},
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
if (dto.phone !== undefined) {
|
||||
if (dto.phone !== undefined && dto.phone != null) {
|
||||
const phone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new BadRequestException('请输入正确的登录手机号');
|
||||
@@ -254,18 +286,22 @@ export class AdminPartnersService {
|
||||
assertPartnerCommissionRates(city, orderCommissionRate, redeemCommissionRate);
|
||||
}
|
||||
|
||||
const phoneChanged =
|
||||
dto.phone !== undefined && dto.phone.trim() !== existing.phone;
|
||||
const nextPhone = dto.phone != null ? dto.phone.trim() : undefined;
|
||||
const phoneChanged = nextPhone !== undefined && nextPhone !== existing.phone;
|
||||
const nextName = dto.name != null ? dto.name.trim() : undefined;
|
||||
const nextCompanyName = trimOptionalText(dto.companyName);
|
||||
const nextAddress = trimOptionalText(dto.address);
|
||||
const nextContactPhone = trimOptionalText(dto.contactPhone);
|
||||
|
||||
const account = await this.prisma.partnerAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||
...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}),
|
||||
...(nextName !== undefined ? { name: nextName } : {}),
|
||||
...(nextPhone !== undefined ? { phone: nextPhone } : {}),
|
||||
...(phoneChanged ? { wxOpenId: null, wxUnionId: null } : {}),
|
||||
...(dto.companyName !== undefined ? { companyName: dto.companyName.trim() } : {}),
|
||||
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
|
||||
...(dto.contactPhone !== undefined ? { contactPhone: dto.contactPhone.trim() } : {}),
|
||||
...(nextCompanyName !== undefined ? { companyName: nextCompanyName } : {}),
|
||||
...(nextAddress !== undefined ? { address: nextAddress } : {}),
|
||||
...(nextContactPhone !== undefined ? { contactPhone: nextContactPhone } : {}),
|
||||
...(dto.scopeType !== undefined ? { scopeType: dto.scopeType as CityPartnerScopeType } : {}),
|
||||
...(dto.scopeType !== undefined || dto.districtCodes !== undefined
|
||||
? {
|
||||
@@ -275,8 +311,12 @@ export class AdminPartnersService {
|
||||
: Prisma.JsonNull,
|
||||
}
|
||||
: {}),
|
||||
...(dto.orderCommissionRate !== undefined ? { orderCommissionRate: dto.orderCommissionRate } : {}),
|
||||
...(dto.redeemCommissionRate !== undefined ? { redeemCommissionRate: dto.redeemCommissionRate } : {}),
|
||||
...(dto.orderCommissionRate !== undefined
|
||||
? { orderCommissionRate: toCommissionDecimal(dto.orderCommissionRate) }
|
||||
: {}),
|
||||
...(dto.redeemCommissionRate !== undefined
|
||||
? { redeemCommissionRate: toCommissionDecimal(dto.redeemCommissionRate) }
|
||||
: {}),
|
||||
...(dto.bindingStatus !== undefined ? { bindingStatus: dto.bindingStatus as CityPartnerStatus } : {}),
|
||||
...(dto.contractNo !== undefined ? { contractNo: dto.contractNo } : {}),
|
||||
...(dto.bankAccountName !== undefined ? { bankAccountName: dto.bankAccountName } : {}),
|
||||
|
||||
@@ -54,6 +54,9 @@ export class AdminStoreRatingsService {
|
||||
id: r.id,
|
||||
serviceScore: r.serviceScore,
|
||||
envScore: r.envScore,
|
||||
comment: r.comment,
|
||||
tags: r.tags,
|
||||
imageUrls: r.imageUrls,
|
||||
createdAt: r.createdAt,
|
||||
store: r.store,
|
||||
redeemRecordId: r.redeemRecordId,
|
||||
|
||||
@@ -8,7 +8,12 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
import { AdminUsersQueryDto } from './dto/admin-query.dto';
|
||||
import { BatchDeleteUsersConfirmDto, BatchDeleteUsersDto, UpdateAdminUserDto } from './dto/admin-mutate.dto';
|
||||
import {
|
||||
BatchDeleteUsersConfirmDto,
|
||||
BatchDeleteUsersDto,
|
||||
UpdateAdminUserDto,
|
||||
UpdateUserAssocDto,
|
||||
} from './dto/admin-mutate.dto';
|
||||
|
||||
@Controller('admin/users')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@@ -48,6 +53,19 @@ export class AdminUsersController {
|
||||
return this.usersService.detail(BigInt(id));
|
||||
}
|
||||
|
||||
@Put(':id/assoc')
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('users_partner_assoc')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.USER_ASSOC_UPDATE,
|
||||
refType: 'USER',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
setAssoc(@Param('id') id: string, @Body() dto: UpdateUserAssocDto) {
|
||||
return this.usersService.setAssoc(BigInt(id), dto.partnerAccountId);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.USER_UPDATE,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { PartnerAssocService } from '../store/partner-assoc.service';
|
||||
import type { AdminUsersQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
const FINISHED_ORDER_STATUSES = ['COMPLETED', 'CANCELLED', 'REFUNDED'] as const;
|
||||
@@ -28,6 +29,21 @@ function toAmount(v: Prisma.Decimal | number | null | undefined) {
|
||||
return v == null ? 0 : Number(v);
|
||||
}
|
||||
|
||||
function mapAssocPartner(p?: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
companyName: string | null;
|
||||
phone: string | null;
|
||||
} | null) {
|
||||
if (!p) return null;
|
||||
return {
|
||||
id: p.id.toString(),
|
||||
name: p.name,
|
||||
companyName: p.companyName,
|
||||
phone: p.phone,
|
||||
};
|
||||
}
|
||||
|
||||
function mapAdminUserRow(u: {
|
||||
id: bigint;
|
||||
userNo: string;
|
||||
@@ -46,6 +62,12 @@ function mapAdminUserRow(u: {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
_count: { orders: number };
|
||||
assocPartner?: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
companyName: string | null;
|
||||
phone: string | null;
|
||||
} | null;
|
||||
}, benefit: UserBenefitStat = EMPTY_BENEFIT_STAT) {
|
||||
return {
|
||||
id: u.id,
|
||||
@@ -66,13 +88,17 @@ function mapAdminUserRow(u: {
|
||||
createdAt: u.createdAt,
|
||||
updatedAt: u.updatedAt,
|
||||
orderCount: u._count.orders,
|
||||
assocPartner: mapAssocPartner(u.assocPartner),
|
||||
...benefit,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminUsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly partnerAssocService: PartnerAssocService,
|
||||
) {}
|
||||
|
||||
async list(query: AdminUsersQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
@@ -88,6 +114,30 @@ export class AdminUsersService {
|
||||
}
|
||||
where.id = BigInt(userId);
|
||||
}
|
||||
const keyword = query.keyword?.trim();
|
||||
if (keyword) {
|
||||
const keywordOr: Prisma.UserWhereInput[] = [
|
||||
{ userNo: { contains: keyword } },
|
||||
{ nickname: { contains: keyword } },
|
||||
{ hqRemark: { contains: keyword } },
|
||||
{ phone: { contains: keyword } },
|
||||
];
|
||||
if (/^\d+$/.test(keyword)) {
|
||||
keywordOr.push({ id: BigInt(keyword) });
|
||||
}
|
||||
where.OR = keywordOr;
|
||||
}
|
||||
const assocPartnerAccountId = query.assocPartnerAccountId?.trim();
|
||||
if (assocPartnerAccountId === 'none') {
|
||||
where.assocPartnerAccountId = null;
|
||||
} else if (assocPartnerAccountId === 'any') {
|
||||
where.assocPartnerAccountId = { not: null };
|
||||
} else if (assocPartnerAccountId) {
|
||||
if (!/^\d+$/.test(assocPartnerAccountId)) {
|
||||
return serializeBigInt({ items: [], total: 0, page, pageSize });
|
||||
}
|
||||
where.assocPartnerAccountId = BigInt(assocPartnerAccountId);
|
||||
}
|
||||
if (query.deviceKey) where.deviceKey = query.deviceKey;
|
||||
if (query.status !== undefined) where.status = query.status;
|
||||
if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null };
|
||||
@@ -117,6 +167,7 @@ export class AdminUsersService {
|
||||
isTest: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
assocPartner: { select: { id: true, name: true, companyName: true, phone: true } },
|
||||
_count: { select: { orders: true } },
|
||||
},
|
||||
}),
|
||||
@@ -159,6 +210,7 @@ export class AdminUsersService {
|
||||
where: { id },
|
||||
include: {
|
||||
cityPreference: true,
|
||||
assocPartner: { select: { id: true, name: true, companyName: true, phone: true } },
|
||||
mergedInto: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||
orders: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -186,18 +238,33 @@ export class AdminUsersService {
|
||||
|
||||
const benefitMap = await this.loadBenefitStats([user.id]);
|
||||
|
||||
const { assocPartner, ...userRest } = user;
|
||||
return serializeBigInt({
|
||||
...user,
|
||||
...userRest,
|
||||
...(benefitMap.get(user.id.toString()) ?? EMPTY_BENEFIT_STAT),
|
||||
wechatVerified: !!user.wxOpenId,
|
||||
mergedFromCount: user._count.mergedFrom,
|
||||
orderCount: user._count.orders,
|
||||
addressCount: user._count.addresses,
|
||||
sourcePromo,
|
||||
assocPartner: assocPartner
|
||||
? {
|
||||
id: assocPartner.id.toString(),
|
||||
name: assocPartner.name,
|
||||
companyName: assocPartner.companyName,
|
||||
phone: assocPartner.phone,
|
||||
}
|
||||
: null,
|
||||
assocBoundAt: user.assocBoundAt?.toISOString() ?? null,
|
||||
_count: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async setAssoc(id: bigint, partnerAccountId?: string | null) {
|
||||
const raw = partnerAccountId?.trim() || null;
|
||||
return this.partnerAssocService.setUserAssoc(id, raw ? BigInt(raw) : null);
|
||||
}
|
||||
|
||||
async updateUser(id: bigint, dto: { hqRemark: string }) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!user) throw new NotFoundException('用户不存在');
|
||||
|
||||
@@ -364,11 +364,13 @@ export class CreatePartnerDto {
|
||||
districtCodes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
orderCommissionRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
redeemCommissionRate?: number;
|
||||
@@ -412,16 +414,19 @@ export class UpdatePartnerDto {
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v != null)
|
||||
@IsString()
|
||||
companyName?: string;
|
||||
companyName?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v != null)
|
||||
@IsString()
|
||||
address?: string;
|
||||
address?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v != null)
|
||||
@IsString()
|
||||
contactPhone?: string;
|
||||
contactPhone?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['CITY_WIDE', 'DISTRICT'])
|
||||
@@ -433,11 +438,13 @@ export class UpdatePartnerDto {
|
||||
districtCodes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
orderCommissionRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
redeemCommissionRate?: number;
|
||||
@@ -590,11 +597,13 @@ export class BindCityPartnerDto {
|
||||
districtCodes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
orderCommissionRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
redeemCommissionRate?: number;
|
||||
@@ -615,11 +624,13 @@ export class UpdateCityPartnerDto {
|
||||
districtCodes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
orderCommissionRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
redeemCommissionRate?: number;
|
||||
@@ -974,6 +985,13 @@ export class UpdateAdminUserDto {
|
||||
hqRemark: string;
|
||||
}
|
||||
|
||||
export class UpdateUserAssocDto {
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v != null && v !== '')
|
||||
@IsString()
|
||||
partnerAccountId?: string | null;
|
||||
}
|
||||
|
||||
/** HQ 订单发货(目前仅小飞侠 XFX) */
|
||||
export class AdminShipOrderDto {
|
||||
@IsIn(['XFX'])
|
||||
|
||||
@@ -40,6 +40,16 @@ export class PaginationQueryDto {
|
||||
}
|
||||
|
||||
export class AdminUsersQueryDto extends PaginationQueryDto {
|
||||
/** 综合搜索:编号 / 昵称 / 备注 / 手机 / 数字 ID */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
|
||||
/** 关联合伙人:主账号 ID;`none` 未关联;`any` 已关联(全部) */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assocPartnerAccountId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
@@ -136,6 +146,11 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoCodeId?: string;
|
||||
|
||||
/** 本单佣金关联合伙人:主账号 ID,或 `none` 表示无快照 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assocPartnerAccountId?: string;
|
||||
}
|
||||
|
||||
/** HQ 订单导出(筛选 + 勾选范围) */
|
||||
@@ -206,6 +221,10 @@ export class AdminOrdersExportDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoCodeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assocPartnerAccountId?: string;
|
||||
}
|
||||
|
||||
/** 概览页用户/订单 ECharts 聚合筛选 */
|
||||
|
||||
@@ -92,6 +92,10 @@ export class HqProxyOrderCreateDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoCodeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assocPartnerAccountId?: string;
|
||||
}
|
||||
|
||||
export class HqProxyOrderPayDto {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
STORE_RATING_MAX_COMMENT,
|
||||
STORE_RATING_MAX_IMAGES,
|
||||
type SubmitStoreRatingRequest,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
export class SubmitStoreRatingDto implements SubmitStoreRatingRequest {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
redeemRecordId: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
serviceScore: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
envScore: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(STORE_RATING_MAX_COMMENT)
|
||||
comment?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(8)
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(STORE_RATING_MAX_IMAGES)
|
||||
@IsString({ each: true })
|
||||
imageUrls?: string[];
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RedeemPhoneSendLookupSmsDto,
|
||||
} from './dto/phone-redeem.dto';
|
||||
import { RedeemFailureReportDto, RedeemPendingSubmitDto } from './dto/weaknet-redeem.dto';
|
||||
import { SubmitStoreRatingDto } from './dto/submit-rating.dto';
|
||||
|
||||
@Controller('redeem')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -33,8 +34,8 @@ export class UserRedeemController {
|
||||
}
|
||||
|
||||
@Post('ratings')
|
||||
rating(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.redeemService.submitRating(user.actorId, body as never);
|
||||
rating(@CurrentUser() user: AuthUser, @Body() body: SubmitStoreRatingDto) {
|
||||
return this.redeemService.submitRating(user.actorId, body);
|
||||
}
|
||||
|
||||
@Get('records')
|
||||
@@ -49,6 +50,11 @@ export class UserRedeemController {
|
||||
Number(pageSize),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('records/:id')
|
||||
record(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.redeemService.getUserRecord(user.actorId, id);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/redeem')
|
||||
|
||||
@@ -51,6 +51,42 @@ function maskRedeemUserLabel(phone?: string | null): string {
|
||||
return '用户***';
|
||||
}
|
||||
|
||||
function asStringList(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((item) => String(item).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeRatingTags(raw: unknown): string[] {
|
||||
return [...new Set(asStringList(raw))].slice(0, 8);
|
||||
}
|
||||
|
||||
function normalizeRatingImageUrls(raw: unknown): string[] {
|
||||
return asStringList(raw)
|
||||
.filter((url) => /^https?:\/\//i.test(url))
|
||||
.slice(0, 6);
|
||||
}
|
||||
|
||||
function mapStoreRating(
|
||||
rating: {
|
||||
serviceScore: number;
|
||||
envScore: number;
|
||||
comment?: string | null;
|
||||
tags?: unknown;
|
||||
imageUrls?: unknown;
|
||||
createdAt?: Date;
|
||||
} | null,
|
||||
) {
|
||||
if (!rating) return null;
|
||||
return {
|
||||
serviceScore: rating.serviceScore,
|
||||
envScore: rating.envScore,
|
||||
comment: rating.comment ?? '',
|
||||
tags: asStringList(rating.tags),
|
||||
imageUrls: asStringList(rating.imageUrls),
|
||||
createdAt: rating.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
type PendingSnapshot = TokenPayload & {
|
||||
redeemType: 'DIRECT' | 'COUPON';
|
||||
};
|
||||
@@ -1264,7 +1300,7 @@ export class RedeemService {
|
||||
take,
|
||||
include: {
|
||||
store: { select: { id: true, name: true } },
|
||||
rating: { select: { serviceScore: true, envScore: true } },
|
||||
rating: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.redeemRecord.count({ where: { userId } }),
|
||||
@@ -1278,9 +1314,7 @@ export class RedeemService {
|
||||
storeId: r.storeId,
|
||||
storeName: r.store?.name ?? '门店',
|
||||
createdAt: r.createdAt,
|
||||
rating: r.rating
|
||||
? { serviceScore: r.rating.serviceScore, envScore: r.rating.envScore }
|
||||
: null,
|
||||
rating: mapStoreRating(r.rating),
|
||||
})),
|
||||
),
|
||||
total,
|
||||
@@ -1289,6 +1323,26 @@ export class RedeemService {
|
||||
};
|
||||
}
|
||||
|
||||
async getUserRecord(userId: bigint, recordId: string) {
|
||||
const record = await this.prisma.redeemRecord.findFirst({
|
||||
where: { id: BigInt(recordId), userId },
|
||||
include: {
|
||||
store: { select: { id: true, name: true } },
|
||||
rating: true,
|
||||
},
|
||||
});
|
||||
if (!record) throw new NotFoundException('核销记录不存在');
|
||||
return serializeBigInt({
|
||||
id: record.id,
|
||||
redeemNo: record.redeemNo,
|
||||
amount: Number(record.amount),
|
||||
storeId: record.storeId,
|
||||
storeName: record.store?.name ?? '门店',
|
||||
createdAt: record.createdAt,
|
||||
rating: mapStoreRating(record.rating),
|
||||
});
|
||||
}
|
||||
|
||||
/** C 端门店详情走马灯:脱敏用户 + 时间 + 金额 */
|
||||
async listPublicStoreRecentRedeems(storeId: bigint, limit = 20) {
|
||||
const take = Math.min(Math.max(limit, 1), 50);
|
||||
@@ -1334,7 +1388,17 @@ export class RedeemService {
|
||||
});
|
||||
}
|
||||
|
||||
async submitRating(userId: bigint, body: { redeemRecordId: string; serviceScore: number; envScore: number }) {
|
||||
async submitRating(
|
||||
userId: bigint,
|
||||
body: {
|
||||
redeemRecordId: string;
|
||||
serviceScore: number;
|
||||
envScore: number;
|
||||
comment?: string;
|
||||
tags?: string[];
|
||||
imageUrls?: string[];
|
||||
},
|
||||
) {
|
||||
const record = await this.prisma.redeemRecord.findFirst({
|
||||
where: { id: BigInt(body.redeemRecordId), userId },
|
||||
});
|
||||
@@ -1342,15 +1406,21 @@ export class RedeemService {
|
||||
const existing = await this.prisma.storeRating.findUnique({
|
||||
where: { redeemRecordId: record.id },
|
||||
});
|
||||
if (existing) return serializeBigInt(existing);
|
||||
if (existing) throw new BadRequestException('该笔核销已评价');
|
||||
const tags = normalizeRatingTags(body.tags);
|
||||
const imageUrls = normalizeRatingImageUrls(body.imageUrls);
|
||||
const comment = String(body.comment || '').trim().slice(0, 200) || null;
|
||||
const rating = await this.prisma.storeRating.create({
|
||||
data: {
|
||||
redeemRecordId: record.id,
|
||||
storeId: record.storeId,
|
||||
serviceScore: body.serviceScore,
|
||||
envScore: body.envScore,
|
||||
comment,
|
||||
tags,
|
||||
imageUrls,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(rating);
|
||||
return serializeBigInt(mapStoreRating(rating));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,51 @@ function wineryDayWindow(anchor = new Date(), lagDays = WINERY_SETTLEMENT_LAG_DA
|
||||
return { start, end, billDate: start };
|
||||
}
|
||||
|
||||
function toPartnerBillItemDto(row: {
|
||||
id: bigint;
|
||||
kind: string;
|
||||
refId: bigint;
|
||||
refNo: string;
|
||||
title: string | null;
|
||||
extra: string | null;
|
||||
baseAmount: Prisma.Decimal | number;
|
||||
rate: Prisma.Decimal | number;
|
||||
commission: Prisma.Decimal | number;
|
||||
occurredAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
kind: row.kind,
|
||||
refId: row.refId.toString(),
|
||||
refNo: row.refNo,
|
||||
title: row.title,
|
||||
extra: row.extra,
|
||||
baseAmount: Number(row.baseAmount),
|
||||
rate: Number(row.rate),
|
||||
commission: Number(row.commission),
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function splitPartnerBillItems(
|
||||
items: Array<{
|
||||
id: bigint;
|
||||
kind: string;
|
||||
refId: bigint;
|
||||
refNo: string;
|
||||
title: string | null;
|
||||
extra: string | null;
|
||||
baseAmount: Prisma.Decimal | number;
|
||||
rate: Prisma.Decimal | number;
|
||||
commission: Prisma.Decimal | number;
|
||||
occurredAt: Date;
|
||||
}>,
|
||||
) {
|
||||
const orderItems = items.filter((i) => i.kind === 'ORDER').map(toPartnerBillItemDto);
|
||||
const redeemItems = items.filter((i) => i.kind === 'REDEEM').map(toPartnerBillItemDto);
|
||||
return { orderItems, redeemItems };
|
||||
}
|
||||
|
||||
function round2(n: number) {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
@@ -1526,6 +1571,7 @@ export class SettlementService implements OnModuleInit {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const bill = await this.prisma.partnerBill.findFirst({
|
||||
where: { id: billId, partnerAccountId: primary.id },
|
||||
include: { items: { orderBy: { occurredAt: 'asc' } } },
|
||||
});
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
@@ -1535,7 +1581,12 @@ export class SettlementService implements OnModuleInit {
|
||||
refId: billId,
|
||||
extraJson: { billId: billId.toString(), status: bill.status },
|
||||
});
|
||||
return serializeBigInt(bill);
|
||||
const { items, ...header } = bill;
|
||||
return serializeBigInt({
|
||||
...header,
|
||||
partnerId: header.partnerAccountId.toString(),
|
||||
...splitPartnerBillItems(items),
|
||||
});
|
||||
}
|
||||
|
||||
async listAdminPartnerBills(query: {
|
||||
@@ -1689,10 +1740,15 @@ export class SettlementService implements OnModuleInit {
|
||||
async getAdminPartnerBill(id: bigint) {
|
||||
const bill = await this.prisma.partnerBill.findUnique({
|
||||
where: { id },
|
||||
include: { partnerAccount: true },
|
||||
include: { partnerAccount: true, items: { orderBy: { occurredAt: 'asc' } } },
|
||||
});
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
return serializeBigInt(bill);
|
||||
const { items, ...header } = bill;
|
||||
return serializeBigInt({
|
||||
...header,
|
||||
partnerId: header.partnerAccountId.toString(),
|
||||
...splitPartnerBillItems(items),
|
||||
});
|
||||
}
|
||||
|
||||
async generatePartnerBill(
|
||||
@@ -1717,30 +1773,39 @@ export class SettlementService implements OnModuleInit {
|
||||
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: primary.cityId,
|
||||
partnerAccountIdAtPay: primary.id,
|
||||
payStatus: 'PAID',
|
||||
paidAt: { gte: periodStart, lte: periodEnd },
|
||||
},
|
||||
orderBy: { paidAt: 'asc' },
|
||||
});
|
||||
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 orderRows = orders.map((o) => {
|
||||
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
|
||||
const baseAmount = Number(o.payAmount);
|
||||
return {
|
||||
kind: 'ORDER' as const,
|
||||
refId: o.id,
|
||||
refNo: o.orderNo,
|
||||
title: o.productName,
|
||||
extra: `×${o.quantity}`,
|
||||
baseAmount,
|
||||
rate,
|
||||
commission: round2(baseAmount * rate),
|
||||
occurredAt: o.paidAt ?? o.createdAt,
|
||||
};
|
||||
});
|
||||
const orderCommission = orderRows.reduce((sum, r) => sum + r.commission, 0);
|
||||
|
||||
const stores = await this.prisma.store.findMany({
|
||||
where: { partnerAccountId: primary.id },
|
||||
select: { id: true },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const storeIds = stores.map((s) => s.id);
|
||||
const storeNameById = new Map(stores.map((s) => [s.id.toString(), s.name]));
|
||||
const redeems = !storeIds.length
|
||||
? []
|
||||
: await this.prisma.redeemRecord.findMany({
|
||||
@@ -1748,37 +1813,71 @@ export class SettlementService implements OnModuleInit {
|
||||
storeId: { in: storeIds },
|
||||
createdAt: { gte: periodStart, lte: periodEnd },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const redeemCommission = redeems.reduce(
|
||||
(sum, r) => sum + Number(r.amount) * redeemCommissionRate,
|
||||
0,
|
||||
);
|
||||
const redeemRows = redeems.map((r) => {
|
||||
const baseAmount = Number(r.amount);
|
||||
return {
|
||||
kind: 'REDEEM' as const,
|
||||
refId: r.id,
|
||||
refNo: r.redeemNo,
|
||||
title: storeNameById.get(r.storeId.toString()) ?? '门店',
|
||||
extra: null as string | null,
|
||||
baseAmount,
|
||||
rate: redeemCommissionRate,
|
||||
commission: round2(baseAmount * redeemCommissionRate),
|
||||
occurredAt: r.createdAt,
|
||||
};
|
||||
});
|
||||
const redeemCommission = redeemRows.reduce((sum, r) => sum + r.commission, 0);
|
||||
|
||||
const totalAmount = round2(orderCommission + redeemCommission);
|
||||
const itemRows = [...orderRows, ...redeemRows];
|
||||
|
||||
const bill = existing
|
||||
? await this.prisma.partnerBill.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
orderCommission: round2(orderCommission),
|
||||
redeemCommission: round2(redeemCommission),
|
||||
totalAmount,
|
||||
periodEnd,
|
||||
status: 'PENDING_REVIEW',
|
||||
},
|
||||
})
|
||||
: await this.prisma.partnerBill.create({
|
||||
data: {
|
||||
billNo: generateBillNo('PB'),
|
||||
partnerAccountId: primary.id,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
orderCommission: round2(orderCommission),
|
||||
redeemCommission: round2(redeemCommission),
|
||||
totalAmount,
|
||||
status: 'PENDING_REVIEW',
|
||||
},
|
||||
const bill = await this.prisma.$transaction(async (tx) => {
|
||||
const header = existing
|
||||
? await tx.partnerBill.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
orderCommission: round2(orderCommission),
|
||||
redeemCommission: round2(redeemCommission),
|
||||
totalAmount,
|
||||
periodEnd,
|
||||
status: 'PENDING_REVIEW',
|
||||
},
|
||||
})
|
||||
: await tx.partnerBill.create({
|
||||
data: {
|
||||
billNo: generateBillNo('PB'),
|
||||
partnerAccountId: primary.id,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
orderCommission: round2(orderCommission),
|
||||
redeemCommission: round2(redeemCommission),
|
||||
totalAmount,
|
||||
status: 'PENDING_REVIEW',
|
||||
},
|
||||
});
|
||||
|
||||
await tx.partnerBillItem.deleteMany({ where: { partnerBillId: header.id } });
|
||||
if (itemRows.length > 0) {
|
||||
await tx.partnerBillItem.createMany({
|
||||
data: itemRows.map((row) => ({
|
||||
partnerBillId: header.id,
|
||||
kind: row.kind,
|
||||
refId: row.refId,
|
||||
refNo: row.refNo,
|
||||
title: row.title,
|
||||
extra: row.extra,
|
||||
baseAmount: row.baseAmount,
|
||||
rate: new Prisma.Decimal(row.rate.toFixed(4)),
|
||||
commission: row.commission,
|
||||
occurredAt: row.occurredAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return header;
|
||||
});
|
||||
|
||||
if (opts?.notify !== false) {
|
||||
let cityName = '—';
|
||||
@@ -1980,6 +2079,7 @@ export class SettlementService implements OnModuleInit {
|
||||
bankBranch: true,
|
||||
},
|
||||
},
|
||||
items: { orderBy: { occurredAt: 'asc' } },
|
||||
},
|
||||
orderBy: { periodStart: 'desc' },
|
||||
});
|
||||
@@ -2024,7 +2124,25 @@ export class SettlementService implements OnModuleInit {
|
||||
csvEscape(b.rejectReason ?? ''),
|
||||
].join(','),
|
||||
);
|
||||
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length };
|
||||
|
||||
const itemHeader = ['账单号', '类型', '单号', '标题', '备注', '基数', '费率', '佣金', '发生时间'].join(',');
|
||||
const itemRows = bills.flatMap((b) =>
|
||||
b.items.map((it) =>
|
||||
[
|
||||
csvEscape(b.billNo),
|
||||
it.kind === 'ORDER' ? '酒订单' : '核销',
|
||||
csvEscape(it.refNo),
|
||||
csvEscape(it.title ?? ''),
|
||||
csvEscape(it.extra ?? ''),
|
||||
Number(it.baseAmount),
|
||||
Number(it.rate),
|
||||
Number(it.commission),
|
||||
it.occurredAt.toISOString().slice(0, 19).replace('T', ' '),
|
||||
].join(','),
|
||||
),
|
||||
);
|
||||
const csv = ['账单汇总', header, ...rows, '', '酒订单/核销明细', itemHeader, ...itemRows].join('\n');
|
||||
return { csv: `\uFEFF${csv}`, count: bills.length };
|
||||
}
|
||||
|
||||
// ─── Winery bills ────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { PartnerAssocService } from './partner-assoc.service';
|
||||
|
||||
class BindPartnerAssocDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scene?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerId?: string;
|
||||
}
|
||||
|
||||
class PartnerAssocRemarkDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
@Controller('user/partner-assoc')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserPartnerAssocController {
|
||||
constructor(private readonly assoc: PartnerAssocService) {}
|
||||
|
||||
@Post('bind')
|
||||
bind(@CurrentUser() user: AuthUser, @Body() dto: BindPartnerAssocDto) {
|
||||
return this.assoc.bindUser(user.actorId, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/assoc')
|
||||
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
||||
export class PartnerAssocController {
|
||||
constructor(private readonly assoc: PartnerAssocService) {}
|
||||
|
||||
@Get()
|
||||
summary(@CurrentUser() user: AuthUser) {
|
||||
return this.assoc.getSummary(user.actorId);
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
stats(@CurrentUser() user: AuthUser) {
|
||||
return this.assoc.getStats(user.actorId);
|
||||
}
|
||||
|
||||
@Get('orders')
|
||||
assocOrders(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.assoc.listAssocOrders(user.actorId, Number(page) || 1, Number(pageSize) || 20);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
users(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('sort') sort?: string,
|
||||
) {
|
||||
const allowed = sort === 'createdAt' || sort === 'boundAt' || sort === 'orderCount' ? sort : undefined;
|
||||
return this.assoc.listUsers(user.actorId, Number(page) || 1, Number(pageSize) || 20, true, {
|
||||
keyword,
|
||||
sort: allowed,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('users/:userId/orders')
|
||||
userOrders(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('userId') userId: string,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.assoc.listAssocOrders(
|
||||
user.actorId,
|
||||
Number(page) || 1,
|
||||
Number(pageSize) || 20,
|
||||
BigInt(userId),
|
||||
);
|
||||
}
|
||||
|
||||
@Put('users/:userId/remark')
|
||||
setRemark(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: PartnerAssocRemarkDto,
|
||||
) {
|
||||
return this.assoc.setUserRemark(user.actorId, BigInt(userId), dto.remark);
|
||||
}
|
||||
|
||||
@Get('qrcode')
|
||||
async qrcode(@CurrentUser() user: AuthUser, @Res() res: Response) {
|
||||
const { buffer, fileName } = await this.assoc.getQrcodeBuffer(user.actorId);
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||
res.send(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/commissions')
|
||||
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
||||
export class PartnerCommissionController {
|
||||
constructor(private readonly assoc: PartnerAssocService) {}
|
||||
|
||||
@Get('orders')
|
||||
orders(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.assoc.listCommissionOrders(user.actorId, Number(page) || 1, Number(pageSize) || 20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||
|
||||
const ASSOC_SCENE_PREFIX = 'pa_';
|
||||
|
||||
function maskPhoneNumber(phone: string | null) {
|
||||
if (!phone || phone.length < 7) return phone;
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
function dayBounds(now = new Date()) {
|
||||
const todayStart = new Date(now);
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
return { todayStart, monthStart };
|
||||
}
|
||||
|
||||
export function parseAssocScene(raw?: string | null): string | null {
|
||||
const s = String(raw ?? '').trim();
|
||||
if (!s) return null;
|
||||
if (s.startsWith(ASSOC_SCENE_PREFIX)) {
|
||||
const id = s.slice(ASSOC_SCENE_PREFIX.length);
|
||||
return /^\d+$/.test(id) ? id : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PartnerAssocService {
|
||||
private readonly logger = new Logger(PartnerAssocService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
||||
) {}
|
||||
|
||||
async bindUser(userId: bigint, input: { scene?: string; partnerId?: string }) {
|
||||
const partnerIdRaw = parseAssocScene(input.scene) || input.partnerId?.trim();
|
||||
if (!partnerIdRaw || !/^\d+$/.test(partnerIdRaw)) {
|
||||
throw new BadRequestException('关联码无效');
|
||||
}
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(BigInt(partnerIdRaw));
|
||||
if (primary.isPrimary !== 1 || primary.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('合伙人不存在或已停用');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('用户不存在');
|
||||
|
||||
if (user.assocPartnerAccountId) {
|
||||
if (user.assocPartnerAccountId === primary.id) {
|
||||
return {
|
||||
bound: true,
|
||||
alreadyBound: true,
|
||||
partnerId: primary.id.toString(),
|
||||
partnerName: primary.companyName || primary.name,
|
||||
};
|
||||
}
|
||||
throw new BadRequestException('您已关联其他合伙人,无法更换');
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
assocPartnerAccountId: primary.id,
|
||||
assocBoundAt: new Date(),
|
||||
...(user.sourceType === 'ORGANIC'
|
||||
? { sourceType: 'PARTNER_ASSOC', sourceRefId: primary.id }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
bound: true,
|
||||
alreadyBound: false,
|
||||
partnerId: primary.id.toString(),
|
||||
partnerName: primary.companyName || primary.name,
|
||||
};
|
||||
}
|
||||
|
||||
async unbindUser(userId: bigint) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('用户不存在');
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { assocPartnerAccountId: null, assocBoundAt: null },
|
||||
});
|
||||
return { ok: true, partnerId: null, partnerName: null };
|
||||
}
|
||||
|
||||
/** HQ 改绑:可换绑或清空;不影响已支付订单快照 */
|
||||
async setUserAssoc(userId: bigint, partnerAccountId: bigint | null) {
|
||||
if (!partnerAccountId) {
|
||||
return this.unbindUser(userId);
|
||||
}
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
if (primary.isPrimary !== 1 || primary.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('合伙人不存在或已停用');
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('用户不存在');
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
assocPartnerAccountId: primary.id,
|
||||
assocBoundAt: new Date(),
|
||||
...(user.sourceType === 'ORGANIC'
|
||||
? { sourceType: 'PARTNER_ASSOC', sourceRefId: primary.id }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
partnerId: primary.id.toString(),
|
||||
partnerName: primary.companyName || primary.name,
|
||||
};
|
||||
}
|
||||
|
||||
async getSummary(partnerAccountId: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const ensured = await this.ensureQrcode(primary.id);
|
||||
const userCount = await this.prisma.user.count({
|
||||
where: { assocPartnerAccountId: primary.id },
|
||||
});
|
||||
return {
|
||||
partnerId: primary.id.toString(),
|
||||
qrcodeUrl: ensured.qrcodeUrl,
|
||||
userCount,
|
||||
companyName: primary.companyName,
|
||||
name: primary.name,
|
||||
};
|
||||
}
|
||||
|
||||
async getStats(partnerAccountId: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const { todayStart, monthStart } = dayBounds();
|
||||
const userWhere = { assocPartnerAccountId: primary.id };
|
||||
const orderWhere = {
|
||||
payStatus: 'PAID' as const,
|
||||
user: { assocPartnerAccountId: primary.id },
|
||||
};
|
||||
const [userTotal, userToday, userMonth, orderTotal, orderToday, orderMonth] = await Promise.all([
|
||||
this.prisma.user.count({ where: userWhere }),
|
||||
this.prisma.user.count({ where: { ...userWhere, assocBoundAt: { gte: todayStart } } }),
|
||||
this.prisma.user.count({ where: { ...userWhere, assocBoundAt: { gte: monthStart } } }),
|
||||
this.prisma.order.count({ where: orderWhere }),
|
||||
this.prisma.order.count({ where: { ...orderWhere, paidAt: { gte: todayStart } } }),
|
||||
this.prisma.order.count({ where: { ...orderWhere, paidAt: { gte: monthStart } } }),
|
||||
]);
|
||||
return { userTotal, userToday, userMonth, orderTotal, orderToday, orderMonth };
|
||||
}
|
||||
|
||||
async listUsers(
|
||||
partnerAccountId: bigint,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
maskPhone = false,
|
||||
opts: { keyword?: string; sort?: 'createdAt' | 'boundAt' | 'orderCount' } = {},
|
||||
) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const keyword = opts.keyword?.trim();
|
||||
const where: Prisma.UserWhereInput = { assocPartnerAccountId: primary.id };
|
||||
if (keyword) {
|
||||
where.OR = [
|
||||
{ userNo: { contains: keyword } },
|
||||
{ nickname: { contains: keyword } },
|
||||
{ phone: { contains: keyword } },
|
||||
{
|
||||
partnerNotes: {
|
||||
some: { partnerAccountId: primary.id, remark: { contains: keyword } },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
const sort = opts.sort ?? 'boundAt';
|
||||
const orderBy: Prisma.UserOrderByWithRelationInput =
|
||||
sort === 'createdAt'
|
||||
? { createdAt: 'desc' }
|
||||
: sort === 'orderCount'
|
||||
? { orders: { _count: 'desc' } }
|
||||
: { assocBoundAt: 'desc' };
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
userNo: true,
|
||||
nickname: true,
|
||||
hqRemark: true,
|
||||
phone: true,
|
||||
createdAt: true,
|
||||
assocBoundAt: true,
|
||||
partnerNotes: {
|
||||
where: { partnerAccountId: primary.id },
|
||||
select: { remark: true },
|
||||
take: 1,
|
||||
},
|
||||
_count: { select: { orders: { where: { payStatus: 'PAID' } } } },
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
items: items.map((u) => ({
|
||||
id: u.id.toString(),
|
||||
userNo: u.userNo,
|
||||
nickname: u.nickname,
|
||||
...(maskPhone
|
||||
? { partnerRemark: u.partnerNotes[0]?.remark ?? null }
|
||||
: { hqRemark: u.hqRemark }),
|
||||
phone: maskPhone ? maskPhoneNumber(u.phone) : u.phone,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
boundAt: u.assocBoundAt?.toISOString() ?? '',
|
||||
orderCount: u._count.orders,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async listAssocOrders(partnerAccountId: bigint, page = 1, pageSize = 20, userId?: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
if (userId) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { assocPartnerAccountId: true },
|
||||
});
|
||||
if (!user || user.assocPartnerAccountId !== primary.id) {
|
||||
throw new NotFoundException('用户未关联本合伙人');
|
||||
}
|
||||
}
|
||||
const where: Prisma.OrderWhereInput = {
|
||||
payStatus: 'PAID',
|
||||
user: { assocPartnerAccountId: primary.id },
|
||||
...(userId ? { userId } : {}),
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
orderBy: { paidAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
orderNo: true,
|
||||
productName: true,
|
||||
quantity: true,
|
||||
payAmount: true,
|
||||
paidAt: true,
|
||||
status: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
items: items.map((o) => ({
|
||||
id: o.id.toString(),
|
||||
orderNo: o.orderNo,
|
||||
productName: o.productName,
|
||||
quantity: o.quantity,
|
||||
payAmount: Number(o.payAmount),
|
||||
paidAt: o.paidAt?.toISOString() ?? null,
|
||||
status: o.status,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async setUserRemark(partnerAccountId: bigint, userId: bigint, remark?: string | null) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { assocPartnerAccountId: true },
|
||||
});
|
||||
if (!user || user.assocPartnerAccountId !== primary.id) {
|
||||
throw new NotFoundException('用户未关联本合伙人');
|
||||
}
|
||||
const text = remark?.trim() || '';
|
||||
if (!text) {
|
||||
await this.prisma.partnerUserNote.deleteMany({
|
||||
where: { partnerAccountId: primary.id, userId },
|
||||
});
|
||||
return { ok: true, remark: null };
|
||||
}
|
||||
if (text.length > 128) {
|
||||
throw new BadRequestException('备注最多 128 字');
|
||||
}
|
||||
const row = await this.prisma.partnerUserNote.upsert({
|
||||
where: { partnerAccountId_userId: { partnerAccountId: primary.id, userId } },
|
||||
create: { partnerAccountId: primary.id, userId, remark: text },
|
||||
update: { remark: text },
|
||||
});
|
||||
return { ok: true, remark: row.remark };
|
||||
}
|
||||
|
||||
async listCommissionOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const where = { partnerAccountIdAtPay: primary.id, payStatus: 'PAID' as const };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
orderBy: { paidAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { user: { select: { phone: true } } },
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
items: items.map((o) => {
|
||||
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
|
||||
return {
|
||||
id: o.id.toString(),
|
||||
orderNo: o.orderNo,
|
||||
productName: o.productName,
|
||||
quantity: o.quantity,
|
||||
payAmount: Number(o.payAmount),
|
||||
rate,
|
||||
commission: Math.round(Number(o.payAmount) * rate * 100) / 100,
|
||||
paidAt: o.paidAt?.toISOString() ?? null,
|
||||
userPhone: o.user.phone,
|
||||
};
|
||||
}),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async ensureQrcode(partnerAccountId: bigint, force = false) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
if (!force && primary.assocQrcodeResourceId) {
|
||||
const resource = await this.prisma.commonResource.findUnique({
|
||||
where: { id: primary.assocQrcodeResourceId },
|
||||
});
|
||||
if (resource?.url) {
|
||||
return { qrcodeId: primary.assocQrcodeId, qrcodeUrl: resource.url };
|
||||
}
|
||||
}
|
||||
|
||||
const scene = `${ASSOC_SCENE_PREFIX}${primary.id.toString()}`;
|
||||
if (scene.length > 32) {
|
||||
throw new BadRequestException('合伙人 ID 过长,无法写入小程序码');
|
||||
}
|
||||
const page = (process.env.WX_MINI_PROMO_PAGE || 'pages/home/index').replace(/^\//, '');
|
||||
let pngBuffer: Buffer;
|
||||
try {
|
||||
pngBuffer = await this.wechat.getWxaCodeUnlimited({
|
||||
scene,
|
||||
page,
|
||||
width: 430,
|
||||
checkPath: false,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`assoc qrcode failed partner=${primary.id}: ${err instanceof Error ? err.message : err}`);
|
||||
throw new BadRequestException('生成关联码失败,请稍后重试');
|
||||
}
|
||||
|
||||
const fileName = `partner-assoc-${primary.id}.png`;
|
||||
const uploaded = await this.oss.putObject({
|
||||
bizType: 'QRCODE',
|
||||
mediaType: 'IMAGE',
|
||||
fileName,
|
||||
buffer: pngBuffer,
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
const resource = await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'PARTNER',
|
||||
ownerId: primary.id,
|
||||
bizType: 'QRCODE',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket: uploaded.bucket,
|
||||
ossKey: uploaded.ossKey,
|
||||
url: uploaded.url,
|
||||
fileName,
|
||||
fileSize: BigInt(pngBuffer.length),
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
});
|
||||
await this.prisma.partnerAccount.update({
|
||||
where: { id: primary.id },
|
||||
data: { assocQrcodeId: scene, assocQrcodeResourceId: resource.id },
|
||||
});
|
||||
return { qrcodeId: scene, qrcodeUrl: resource.url };
|
||||
}
|
||||
|
||||
async getQrcodeBuffer(partnerAccountId: bigint): Promise<{ buffer: Buffer; fileName: string }> {
|
||||
const summary = await this.getSummary(partnerAccountId);
|
||||
if (!summary.qrcodeUrl) {
|
||||
throw new NotFoundException('关联码尚未生成');
|
||||
}
|
||||
const res = await fetch(summary.qrcodeUrl);
|
||||
if (!res.ok) throw new BadRequestException('下载关联码失败');
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
return { buffer, fileName: `partner-assoc-${summary.partnerId}.png` };
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,12 @@ import {
|
||||
ShopStoreInfoChangeController,
|
||||
} from './store-info-change.controller';
|
||||
import { StoreInfoChangeService } from './store-info-change.service';
|
||||
import { PartnerAssocService } from './partner-assoc.service';
|
||||
import {
|
||||
PartnerAssocController,
|
||||
PartnerCommissionController,
|
||||
UserPartnerAssocController,
|
||||
} from './partner-assoc.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -56,8 +62,11 @@ import { StoreInfoChangeService } from './store-info-change.service';
|
||||
PartnerStoreInfoChangeController,
|
||||
ShopStoreInfoChangeController,
|
||||
AdminStoreInfoChangeController,
|
||||
UserPartnerAssocController,
|
||||
PartnerAssocController,
|
||||
PartnerCommissionController,
|
||||
],
|
||||
providers: [StoreService, StoreCategoryService, StorePackageService, StoreInfoChangeService],
|
||||
exports: [StoreService, StoreCategoryService, StorePackageService],
|
||||
providers: [StoreService, StoreCategoryService, StorePackageService, StoreInfoChangeService, PartnerAssocService],
|
||||
exports: [StoreService, StoreCategoryService, StorePackageService, PartnerAssocService],
|
||||
})
|
||||
export class StoreModule {}
|
||||
|
||||
@@ -184,7 +184,7 @@ export class StoreService {
|
||||
const stores = await this.prisma.store.findMany({
|
||||
where: where as never,
|
||||
include: {
|
||||
category: true,
|
||||
category: { include: { parent: true } },
|
||||
coverResource: true,
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
@@ -206,8 +206,21 @@ export class StoreService {
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
sortOrder?: number;
|
||||
redeemCount: number;
|
||||
};
|
||||
|
||||
const redeemGroups =
|
||||
visible.length === 0
|
||||
? []
|
||||
: await this.prisma.redeemRecord.groupBy({
|
||||
by: ['storeId'],
|
||||
where: { storeId: { in: visible.map((s) => s.id) } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const redeemCountByStore = new Map(
|
||||
redeemGroups.map((g) => [g.storeId.toString(), g._count._all]),
|
||||
);
|
||||
|
||||
const items: StoreListItem[] = [];
|
||||
for (const store of visible) {
|
||||
const coords = await this.ensureStoreCoordinates(store);
|
||||
@@ -224,7 +237,11 @@ export class StoreService {
|
||||
hasUser && coords
|
||||
? Math.round(haversineMeters(userLat!, userLng!, coords.latitude, coords.longitude))
|
||||
: null;
|
||||
items.push({ ...mapped, distanceMeters });
|
||||
items.push({
|
||||
...mapped,
|
||||
distanceMeters,
|
||||
redeemCount: redeemCountByStore.get(store.id.toString()) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
items.sort((a, b) => {
|
||||
@@ -245,7 +262,7 @@ export class StoreService {
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id, status: 'OPEN' },
|
||||
include: {
|
||||
category: true,
|
||||
category: { include: { parent: true } },
|
||||
coverResource: true,
|
||||
},
|
||||
});
|
||||
@@ -256,14 +273,17 @@ export class StoreService {
|
||||
throw new NotFoundException('门店不存在');
|
||||
}
|
||||
const coords = await this.ensureStoreCoordinates(store);
|
||||
const media = await this.prisma.commonResource.findMany({
|
||||
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
const packageRows = await this.prisma.storePackage.findMany({
|
||||
where: { storeId: id },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
const [media, packageRows, redeemCount] = await Promise.all([
|
||||
this.prisma.commonResource.findMany({
|
||||
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
}),
|
||||
this.prisma.storePackage.findMany({
|
||||
where: { storeId: id },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
}),
|
||||
this.prisma.redeemRecord.count({ where: { storeId: id } }),
|
||||
]);
|
||||
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||
return serializeBigInt(
|
||||
mapStoreCompat(
|
||||
@@ -271,6 +291,7 @@ export class StoreService {
|
||||
...rest,
|
||||
latitude: coords?.latitude ?? store.latitude,
|
||||
longitude: coords?.longitude ?? store.longitude,
|
||||
redeemCount,
|
||||
media,
|
||||
packages: packageRows.map((p) => {
|
||||
const imageUrls = normalizeStorePackageImageUrls({
|
||||
|
||||
@@ -100,6 +100,10 @@ export class PartnerProxyOrderCreateDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoCodeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assocPartnerAccountId?: string;
|
||||
}
|
||||
|
||||
export class PartnerProxyOrderPayDto {
|
||||
|
||||
@@ -436,10 +436,7 @@ export class TradeService {
|
||||
|
||||
const { externalNo } = payResult;
|
||||
const now = new Date();
|
||||
const paySnapshot = await this.partnerCityService.resolveForOrder(
|
||||
order.cityId,
|
||||
order.receiverDistrict,
|
||||
);
|
||||
const paySnapshot = await this.partnerCityService.snapshotForUser(order.userId);
|
||||
// 现场提货:支付即完成(PRD SC-02 / REQ-U-008)
|
||||
const toStatus =
|
||||
order.deliveryType === 'ON_SITE_PICKUP' ? 'COMPLETED' : 'PENDING_SHIP';
|
||||
@@ -452,8 +449,8 @@ export class TradeService {
|
||||
payStatus: 'PAID',
|
||||
paidAt: now,
|
||||
payExternalNo: externalNo,
|
||||
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
|
||||
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
|
||||
partnerAccountIdAtPay: paySnapshot.partnerAccountId,
|
||||
orderCommissionRateAtPay: paySnapshot.orderCommissionRate,
|
||||
},
|
||||
});
|
||||
await tx.logThirdParty.create({
|
||||
@@ -618,10 +615,14 @@ export class TradeService {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const paySnapshot = await this.partnerCityService.resolveForOrder(
|
||||
order.cityId,
|
||||
order.receiverDistrict,
|
||||
);
|
||||
const paySnapshot =
|
||||
order.orderType === 'PROXY'
|
||||
? {
|
||||
partnerAccountId: order.partnerAccountIdAtPay,
|
||||
orderCommissionRate:
|
||||
order.orderCommissionRateAtPay != null ? Number(order.orderCommissionRateAtPay) : null,
|
||||
}
|
||||
: await this.partnerCityService.snapshotForUser(order.userId);
|
||||
// 现场提货:支付即完成(PRD SC-02 / REQ-U-008)
|
||||
const toStatus =
|
||||
order.deliveryType === 'ON_SITE_PICKUP' ? 'COMPLETED' : 'PENDING_SHIP';
|
||||
@@ -636,8 +637,8 @@ export class TradeService {
|
||||
payStatus: 'PAID',
|
||||
paidAt: now,
|
||||
payExternalNo: params.transactionId,
|
||||
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
|
||||
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
|
||||
partnerAccountIdAtPay: paySnapshot.partnerAccountId,
|
||||
orderCommissionRateAtPay: paySnapshot.orderCommissionRate,
|
||||
},
|
||||
});
|
||||
await tx.logThirdParty.create({
|
||||
@@ -1655,6 +1656,14 @@ export class TradeService {
|
||||
cityName: s.cityName,
|
||||
district: s.district,
|
||||
})),
|
||||
partners: [
|
||||
{
|
||||
id: primary.id.toString(),
|
||||
companyName: primary.companyName,
|
||||
name: primary.name,
|
||||
phone: primary.phone,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1789,6 +1798,7 @@ export class TradeService {
|
||||
quantity: number;
|
||||
promoCodeId?: string;
|
||||
skuId?: string;
|
||||
assocPartnerAccountId?: string;
|
||||
},
|
||||
req: Request,
|
||||
) {
|
||||
@@ -1840,7 +1850,6 @@ export class TradeService {
|
||||
let receiverCity = body.city?.trim() || '';
|
||||
let receiverDistrict = body.district?.trim() || '';
|
||||
let receiverAddress = '';
|
||||
let commissionDistrict = receiverDistrict;
|
||||
|
||||
if (body.deliveryMode === 'ON_SITE_PICKUP') {
|
||||
receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
|
||||
@@ -1848,7 +1857,6 @@ export class TradeService {
|
||||
receiverCity = '现场';
|
||||
receiverDistrict = '取货';
|
||||
receiverAddress = '现场提货';
|
||||
commissionDistrict = '';
|
||||
} else {
|
||||
if (!receiverProvince || !receiverCity || !receiverDistrict) {
|
||||
throw new BadRequestException('请选择省市区');
|
||||
@@ -1859,7 +1867,15 @@ export class TradeService {
|
||||
receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`;
|
||||
}
|
||||
|
||||
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict);
|
||||
if (body.assocPartnerAccountId) {
|
||||
const chosen = await this.partnerCityService.resolvePrimaryAccount(BigInt(body.assocPartnerAccountId));
|
||||
if (chosen.id !== primary.id) {
|
||||
throw new BadRequestException('只能关联本合伙人');
|
||||
}
|
||||
}
|
||||
const paySnapshot = body.assocPartnerAccountId
|
||||
? await this.partnerCityService.applyProxyAssoc(user.id, BigInt(body.assocPartnerAccountId))
|
||||
: { partnerAccountId: null as bigint | null, orderCommissionRate: null as number | null };
|
||||
|
||||
const orderNo = generateOrderNo();
|
||||
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
|
||||
@@ -1916,8 +1932,8 @@ export class TradeService {
|
||||
ipCity: location.ipCity,
|
||||
ipDistrict: location.ipDistrict,
|
||||
payExpireAt,
|
||||
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? primary.id,
|
||||
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
|
||||
partnerAccountIdAtPay: paySnapshot.partnerAccountId,
|
||||
orderCommissionRateAtPay: paySnapshot.orderCommissionRate,
|
||||
proxyPartnerAccountId: primary.id,
|
||||
proxyPartnerName: primary.name,
|
||||
proxyPartnerPhone: partnerPhone,
|
||||
@@ -2136,9 +2152,14 @@ export class TradeService {
|
||||
|
||||
/** HQ 代下单:商品/推广码选项(运营侧可看白名单测试酒) */
|
||||
async getHqProxyOrderOptions() {
|
||||
const [products, promoCodes] = await Promise.all([
|
||||
const [products, promoCodes, partners] = await Promise.all([
|
||||
this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }),
|
||||
this.promoCodeService.listActiveOptions(),
|
||||
this.prisma.partnerAccount.findMany({
|
||||
where: { isPrimary: 1, status: 'ACTIVE' },
|
||||
select: { id: true, companyName: true, name: true, phone: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
const details = await Promise.all(
|
||||
products.map((p) =>
|
||||
@@ -2165,6 +2186,12 @@ export class TradeService {
|
||||
})),
|
||||
promoCodes,
|
||||
stores: [],
|
||||
partners: partners.map((p) => ({
|
||||
id: p.id.toString(),
|
||||
companyName: p.companyName,
|
||||
name: p.name,
|
||||
phone: p.phone,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2183,6 +2210,7 @@ export class TradeService {
|
||||
quantity: number;
|
||||
promoCodeId?: string;
|
||||
skuId?: string;
|
||||
assocPartnerAccountId?: string;
|
||||
},
|
||||
req: Request,
|
||||
) {
|
||||
@@ -2237,7 +2265,6 @@ export class TradeService {
|
||||
let receiverCity = body.city?.trim() || '';
|
||||
let receiverDistrict = body.district?.trim() || '';
|
||||
let receiverAddress = '';
|
||||
let commissionDistrict = receiverDistrict;
|
||||
|
||||
if (body.deliveryMode === 'ON_SITE_PICKUP') {
|
||||
receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
|
||||
@@ -2245,7 +2272,6 @@ export class TradeService {
|
||||
receiverCity = '现场';
|
||||
receiverDistrict = '取货';
|
||||
receiverAddress = '现场提货';
|
||||
commissionDistrict = '';
|
||||
} else {
|
||||
if (!receiverProvince || !receiverCity || !receiverDistrict) {
|
||||
throw new BadRequestException('请选择省市区');
|
||||
@@ -2256,7 +2282,9 @@ export class TradeService {
|
||||
receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`;
|
||||
}
|
||||
|
||||
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict);
|
||||
const paySnapshot = body.assocPartnerAccountId
|
||||
? await this.partnerCityService.applyProxyAssoc(user.id, BigInt(body.assocPartnerAccountId))
|
||||
: { partnerAccountId: null as bigint | null, orderCommissionRate: null as number | null };
|
||||
|
||||
const orderNo = generateOrderNo();
|
||||
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
|
||||
@@ -2313,8 +2341,8 @@ export class TradeService {
|
||||
ipCity: location.ipCity,
|
||||
ipDistrict: location.ipDistrict,
|
||||
payExpireAt,
|
||||
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
|
||||
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
|
||||
partnerAccountIdAtPay: paySnapshot.partnerAccountId,
|
||||
orderCommissionRateAtPay: paySnapshot.orderCommissionRate,
|
||||
proxyPartnerAccountId: null,
|
||||
proxyPartnerName: proxyDisplayName,
|
||||
proxyPartnerPhone: operatorPhone,
|
||||
@@ -2612,11 +2640,7 @@ export class TradeService {
|
||||
if (order.payStatus === 'PAID') return;
|
||||
|
||||
const now = new Date();
|
||||
const paySnapshot = await this.partnerCityService.resolveForOrder(
|
||||
order.cityId,
|
||||
order.receiverDistrict,
|
||||
);
|
||||
// 现场提货:支付即完成(代下单同 C 端 PRD)
|
||||
// 现场提货:支付即完成(代下单同 C 端 PRD);佣金快照沿用创建时代下单选择,不回落区县
|
||||
const toStatus =
|
||||
order.deliveryType === 'ON_SITE_PICKUP' ? 'COMPLETED' : 'PENDING_SHIP';
|
||||
|
||||
@@ -2628,9 +2652,8 @@ export class TradeService {
|
||||
payStatus: 'PAID',
|
||||
paidAt: now,
|
||||
payExternalNo: externalNo,
|
||||
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? order.partnerAccountIdAtPay,
|
||||
orderCommissionRateAtPay:
|
||||
paySnapshot?.orderCommissionRate ?? order.orderCommissionRateAtPay,
|
||||
partnerAccountIdAtPay: order.partnerAccountIdAtPay,
|
||||
orderCommissionRateAtPay: order.orderCommissionRateAtPay,
|
||||
},
|
||||
});
|
||||
await tx.logThirdParty.create({
|
||||
|
||||
Reference in New Issue
Block a user