feat(ops): v4.0.4 开发任务多行与版本状态同步、合伙人/门店删除、账单合伙人列

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-31 16:08:55 +08:00
parent e26206b93c
commit ac94f5f5de
15 changed files with 568 additions and 59 deletions
@@ -11,6 +11,7 @@ export const HqOperationAction = {
WAREHOUSE_DELETE: 'WAREHOUSE_DELETE',
PARTNER_CREATE: 'PARTNER_CREATE',
PARTNER_UPDATE: 'PARTNER_UPDATE',
PARTNER_DELETE: 'PARTNER_DELETE',
PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE',
PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE',
PARTNER_ACCOUNT_DELETE: 'PARTNER_ACCOUNT_DELETE',
@@ -29,6 +30,7 @@ export const HqOperationAction = {
ORDER_EXPORT: 'ORDER_EXPORT',
STORE_CREATE: 'STORE_CREATE',
STORE_UPDATE: 'STORE_UPDATE',
STORE_DELETE: 'STORE_DELETE',
STORE_STATUS: 'STORE_STATUS',
STORE_AUDIT: 'STORE_AUDIT',
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
@@ -142,6 +144,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.WAREHOUSE_DELETE]: '删除城市仓库',
[HqOperationAction.PARTNER_CREATE]: '新增城市合伙人',
[HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人',
[HqOperationAction.PARTNER_DELETE]: '删除城市合伙人',
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
[HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户',
[HqOperationAction.PARTNER_ACCOUNT_DELETE]: '删除合伙人子账号',
@@ -160,6 +163,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.ORDER_EXPORT]: '导出订单',
[HqOperationAction.STORE_CREATE]: '新增门店',
[HqOperationAction.STORE_UPDATE]: '编辑门店',
[HqOperationAction.STORE_DELETE]: '删除门店',
[HqOperationAction.STORE_STATUS]: '变更门店状态',
[HqOperationAction.STORE_AUDIT]: '门店审核',
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
@@ -32,7 +32,11 @@ import type {
} from '@dukang/shared-types';
import { taskCompletedAtOnStatus, versionStatusTimestamps } from '@dukang/domain';
import {
resolveTaskStatusFromVersions,
taskCompletedAtOnStatus,
versionStatusTimestamps,
} from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
@@ -530,6 +534,8 @@ export class DevPlanService {
await this.replaceTaskVersions(id, dto.versionIds);
}
await this.syncLinkedTaskStatuses([id]);
return this.getTask(id);
}
@@ -768,8 +774,10 @@ export class DevPlanService {
}
await this.syncLinkedTasksForVersion(row.id);
if (status === 'RELEASED') {
await this.cascadeVersionReleased(row.id, row.versionNo);
await this.cascadeTicketsPublished(row.id, row.versionNo);
}
return this.getVersion(row.id);
@@ -840,15 +848,16 @@ export class DevPlanService {
await this.prisma.devPlanVersion.update({ where: { id }, data });
if (
dto.status === 'RELEASED' &&
existing.status !== 'RELEASED'
) {
const versionNo = (dto.versionNo ?? existing.versionNo).trim();
await this.cascadeVersionReleased(id, versionNo);
if (dto.taskIds != null) await this.replaceVersionTasks(id, dto.taskIds);
if (dto.status != null || dto.taskIds != null) {
await this.syncLinkedTasksForVersion(id);
}
if (dto.taskIds != null) await this.replaceVersionTasks(id, dto.taskIds);
if (dto.status === 'RELEASED' && existing.status !== 'RELEASED') {
const versionNo = (dto.versionNo ?? existing.versionNo).trim();
await this.cascadeTicketsPublished(id, versionNo);
}
return this.getVersion(id);
@@ -880,11 +889,58 @@ export class DevPlanService {
await this.replaceVersionTasks(versionId, taskIds);
await this.syncLinkedTasksForVersion(versionId);
return this.getVersion(versionId);
}
private async cascadeVersionReleased(versionId: bigint, versionNo: string) {
private async syncLinkedTasksForVersion(versionId: bigint) {
const links = await this.prisma.devPlanVersionTask.findMany({
where: { versionId },
select: { taskId: true },
});
await this.syncLinkedTaskStatuses(links.map((l) => l.taskId));
}
private async syncLinkedTaskStatuses(taskIds: bigint[]) {
const uniq = [...new Set(taskIds)];
if (!uniq.length) return;
const [tasks, links] = await Promise.all([
this.prisma.devPlanTask.findMany({
where: { id: { in: uniq } },
select: { id: true, status: true, completedAt: true },
}),
this.prisma.devPlanVersionTask.findMany({
where: { taskId: { in: uniq } },
include: { version: { select: { status: true } } },
}),
]);
const statusesByTask = new Map<string, DevPlanVersionStatus[]>();
for (const link of links) {
const key = String(link.taskId);
const list = statusesByTask.get(key) ?? [];
list.push(link.version.status);
statusesByTask.set(key, list);
}
for (const task of tasks) {
const next = resolveTaskStatusFromVersions(statusesByTask.get(String(task.id)) ?? []);
if (!next || next === task.status) continue;
const completed = taskCompletedAtOnStatus(task.status, next, task.completedAt);
await this.prisma.devPlanTask.update({
where: { id: task.id },
data: {
status: next,
...(completed ? { completedAt: completed } : {}),
},
});
}
}
private async cascadeTicketsPublished(versionId: bigint, versionNo: string) {
const links = await this.prisma.devPlanVersionTask.findMany({
where: { versionId },
select: { taskId: true },
@@ -894,33 +950,26 @@ export class DevPlanService {
const taskIds = links.map((l) => l.taskId);
const now = new Date();
await this.prisma.$transaction(async (tx) => {
await tx.devPlanTask.updateMany({
where: { id: { in: taskIds }, status: { not: 'RELEASED' } },
data: { status: 'RELEASED', completedAt: now },
});
const tasks = await this.prisma.devPlanTask.findMany({
where: { id: { in: taskIds }, supportTicketId: { not: null } },
select: { supportTicketId: true },
});
const ticketIds = [
...new Set(
tasks
.map((t) => t.supportTicketId)
.filter((id): id is bigint => id != null),
),
];
if (!ticketIds.length) return;
const tasks = await tx.devPlanTask.findMany({
where: { id: { in: taskIds }, supportTicketId: { not: null } },
select: { supportTicketId: true },
});
const ticketIds = [
...new Set(
tasks
.map((t) => t.supportTicketId)
.filter((id): id is bigint => id != null),
),
];
if (!ticketIds.length) return;
await tx.commonSupportTicket.updateMany({
where: { id: { in: ticketIds }, status: { not: 'REJECTED' } },
data: {
status: 'PUBLISHED',
releasedVersionNo: versionNo,
publishedAt: now,
},
});
await this.prisma.commonSupportTicket.updateMany({
where: { id: { in: ticketIds }, status: { not: 'REJECTED' } },
data: {
status: 'PUBLISHED',
releasedVersionNo: versionNo,
publishedAt: now,
},
});
}
@@ -976,6 +1025,8 @@ export class DevPlanService {
await this.addVersionTasks(versionId, taskIds);
await this.syncLinkedTasksForVersion(versionId);
return this.getVersion(versionId);
}
@@ -1248,7 +1299,7 @@ export class DevPlanService {
}
await this.syncLinkedTaskStatuses(taskIds);
return { updated: taskIds.length };
@@ -56,6 +56,16 @@ export class AdminPartnersController {
return this.service.updatePartner(BigInt(id), dto);
}
@Delete(':id')
@HqOperation({
action: HqOperationAction.PARTNER_DELETE,
refType: 'PARTNER',
refIdParam: 'id',
})
remove(@Param('id') id: string) {
return this.service.deletePartner(BigInt(id));
}
@Get(':id/assoc')
assocSummary(@Param('id') id: string) {
return this.assoc.getSummary(BigInt(id));
@@ -1,6 +1,10 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, CityPartnerScopeType, CityPartnerStatus } from '@prisma/client';
import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from '@dukang/domain';
import {
assertCanDeleteCityPartner,
resolveMaxPartnerCommissionRate,
validatePartnerCommissionRates,
} from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service';
@@ -502,6 +506,62 @@ export class AdminPartnersService {
return serializeBigInt(account);
}
async deletePartner(id: bigint) {
const account = await this.prisma.partnerAccount.findFirst({
where: { id, ...PRIMARY_WHERE },
include: { children: { select: { id: true } } },
});
if (!account) throw new NotFoundException('开城合伙人不存在');
const accountIds = [id, ...account.children.map((c) => c.id)];
const [storeCount, orderCount, billCount, redeemCount] = await Promise.all([
this.prisma.store.count({ where: { partnerAccountId: { in: accountIds } } }),
this.prisma.order.count({
where: {
OR: [
{ partnerAccountIdAtPay: { in: accountIds } },
{ proxyPartnerAccountId: { in: accountIds } },
],
},
}),
this.prisma.partnerBill.count({ where: { partnerAccountId: { in: accountIds } } }),
this.prisma.redeemRecord.count({
where: { store: { partnerAccountId: { in: accountIds } } },
}),
]);
const check = assertCanDeleteCityPartner({
storeCount,
orderCount: orderCount + billCount,
redeemCount,
});
if (!check.ok) throw new BadRequestException(check.message);
await this.prisma.$transaction(async (tx) => {
await tx.user.updateMany({
where: { assocPartnerAccountId: { in: accountIds } },
data: { assocPartnerAccountId: null, assocBoundAt: null },
});
await tx.partnerUserNote.deleteMany({ where: { partnerAccountId: { in: accountIds } } });
await tx.logPartnerAnalytics.deleteMany({ where: { partnerAccountId: { in: accountIds } } });
await tx.cityWarehouse.updateMany({
where: { partnerAccountId: { in: accountIds } },
data: { partnerAccountId: null },
});
await tx.partnerAccount.updateMany({
where: { id: { in: accountIds } },
data: { managedWarehouseId: null, assocQrcodeResourceId: null, activityPosterId: null },
});
const childIds = account.children.map((c) => c.id);
if (childIds.length) {
await tx.partnerAccount.deleteMany({ where: { id: { in: childIds } } });
}
await tx.partnerAccount.delete({ where: { id } });
});
return { ok: true, message: '城市合伙人已删除' };
}
async deletePartnerSubAccount(id: bigint) {
const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
if (!account) throw new NotFoundException('合伙人账号不存在');
@@ -52,6 +52,12 @@ export class AdminStoresController {
return this.service.updateStore(BigInt(id), dto, user.actorId);
}
@Delete(':id')
@HqOperation({ action: HqOperationAction.STORE_DELETE, refType: 'STORE', refIdParam: 'id' })
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.service.deleteStore(BigInt(id), user.actorId);
}
@Put(':id/status')
@HqOperation({ action: HqOperationAction.STORE_STATUS, refType: 'STORE', refIdParam: 'id', includeBody: true })
updateStatus(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
@@ -1,6 +1,12 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { isMobilePhone, isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain';
import {
assertCanDeleteStore,
isMobilePhone,
isStoreContactPhone,
STORE_CONTACT_PHONE_HINT,
validateBusinessHours,
} from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
@@ -241,6 +247,88 @@ export class AdminStoresService {
return serializeBigInt(store);
}
async deleteStore(id: bigint, actorId: bigint) {
await this.hqPermissions.assertStoreIdInScope(actorId, id);
const store = await this.prisma.store.findUnique({
where: { id },
include: { bindings: { select: { storeAccountId: true } } },
});
if (!store) throw new NotFoundException('门店不存在');
const [redeemCount, pendingCount, payoutCount, billCount, ratingCount, withdrawCount] =
await Promise.all([
this.prisma.redeemRecord.count({ where: { storeId: id } }),
this.prisma.redeemPendingRecord.count({ where: { storeId: id } }),
this.prisma.storePayout.count({ where: { storeId: id } }),
this.prisma.storeBill.count({ where: { storeId: id } }),
this.prisma.storeRating.count({ where: { storeId: id } }),
this.prisma.storeWithdrawRequest.count({ where: { storeId: id } }),
]);
const check = assertCanDeleteStore({
orderCount: 0,
redeemCount:
redeemCount + pendingCount + payoutCount + billCount + ratingCount + withdrawCount,
});
if (!check.ok) throw new BadRequestException(check.message);
const accountIds = [...new Set(store.bindings.map((b) => b.storeAccountId))];
const exclusiveAccountIds: bigint[] = [];
for (const accountId of accountIds) {
const otherBindings = await this.prisma.storeAccountStore.count({
where: { storeAccountId: accountId, NOT: { storeId: id } },
});
if (otherBindings === 0) exclusiveAccountIds.push(accountId);
}
const staffToDelete: bigint[] = [];
if (exclusiveAccountIds.length) {
const staff = await this.prisma.storeAccount.findMany({
where: { parentAccountId: { in: exclusiveAccountIds }, isPrimary: 0 },
select: { id: true },
});
for (const row of staff) {
const other = await this.prisma.storeAccountStore.count({
where: { storeAccountId: row.id, NOT: { storeId: id } },
});
if (other > 0) continue;
const pending = await this.prisma.redeemPendingRecord.count({
where: { storeAccountId: row.id },
});
if (pending > 0) {
throw new BadRequestException('该账号下有关联核销单,禁止删除');
}
staffToDelete.push(row.id);
}
}
await this.prisma.$transaction(async (tx) => {
await tx.store.update({ where: { id }, data: { coverResourceId: null } });
await tx.commonResource.updateMany({
where: { ownerType: 'STORE', ownerId: id },
data: { status: 'DELETED' },
});
await tx.logStoreAnalytics.deleteMany({ where: { storeId: id } });
await tx.store.delete({ where: { id } });
if (staffToDelete.length) {
await tx.logStoreAnalytics.deleteMany({ where: { storeAccountId: { in: staffToDelete } } });
await tx.storeAccount.deleteMany({ where: { id: { in: staffToDelete } } });
}
if (exclusiveAccountIds.length) {
await tx.logStoreAnalytics.deleteMany({
where: { storeAccountId: { in: exclusiveAccountIds } },
});
await tx.storeAccount.updateMany({
where: { parentAccountId: { in: exclusiveAccountIds } },
data: { parentAccountId: null },
});
await tx.storeAccount.deleteMany({ where: { id: { in: exclusiveAccountIds } } });
}
});
return { ok: true, message: '门店及门店账号已删除' };
}
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }, actorId: bigint) {
await this.hqPermissions.assertStoreIdInScope(actorId, id);
const store = await this.prisma.store.findUnique({ where: { id } });