diff --git a/apps/admin-web/src/pages/BenefitCouponsPage.tsx b/apps/admin-web/src/pages/BenefitCouponsPage.tsx index 6f98e87..8ba2db3 100644 --- a/apps/admin-web/src/pages/BenefitCouponsPage.tsx +++ b/apps/admin-web/src/pages/BenefitCouponsPage.tsx @@ -33,6 +33,34 @@ type Row = { order?: { orderNo: string } | null; }; +type CouponRedeemRecord = { + id: string; + redeemNo: string; + amount: number; + settleAmount: number; + couponAmount?: number; + role?: 'PRIMARY' | 'SECONDARY'; + createdAt: string; + store?: { id: string; name: string; cityName?: string | null } | null; +}; + +type CouponRedeemSummary = { + couponNo: string; + totalAmount: number; + usedAmount: number; + balance: number; + status: string; + redeemCount: number; + redeemRecordSum: number; +}; + +type CouponDetail = Row & { + redeemSummary?: CouponRedeemSummary | null; + redeemRecords?: CouponRedeemRecord[]; + user?: { userNo?: string; phone?: string | null }; + order?: { orderNo?: string } | null; +}; + export default function BenefitCouponsPage() { const [form] = Form.useForm(); const [grantForm] = Form.useForm(); @@ -50,8 +78,25 @@ export default function BenefitCouponsPage() { }, [filters], ); - const [detail, setDetail] = useState | null>(null); + const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); + const [redeemDetail, setRedeemDetail] = useState | null>(null); + const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false); + const [redeemDetailLoading, setRedeemDetailLoading] = useState(false); + + async function openRedeemDetail(redeemId: string) { + setRedeemDetailLoading(true); + setRedeemDrawerOpen(true); + try { + const res = await request>(`/admin/redeem-records/${redeemId}`); + setRedeemDetail(res); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载核销详情失败'); + setRedeemDrawerOpen(false); + } finally { + setRedeemDetailLoading(false); + } + } const columns: ColumnsType = [ { title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false }, @@ -197,7 +242,7 @@ export default function BenefitCouponsPage() { setDrawerOpen(false)} extra={ @@ -218,25 +263,202 @@ export default function BenefitCouponsPage() { } > {detail && ( + <> + + {detail.couponNo} + {detail.user?.userNo ?? '—'} + {detail.user?.phone ?? '—'} + {detail.order?.orderNo ?? '—'} + ¥{Number(detail.totalAmount).toFixed(2)} + ¥{Number(detail.balance).toFixed(2)} + + {COUPON_STATUS_LABELS[detail.status] || detail.status} + + {detail.sourceProduct} + + + + 权益核销 + + {detail.redeemSummary ? ( + <> + + {detail.redeemSummary.couponNo} + {detail.redeemSummary.status} + + ¥{Number(detail.redeemSummary.totalAmount).toFixed(2)} + + + + ¥{Number(detail.redeemSummary.usedAmount).toFixed(2)} + + + ({detail.redeemSummary.redeemCount} 笔核销单) + + + + ¥{Number(detail.redeemSummary.balance).toFixed(2)} + + + ¥{Number(detail.redeemSummary.redeemRecordSum).toFixed(2)} + + (本券分摊合计) + + + + + v === 'SECONDARY' ? 次券 : 主券, + }, + { + title: '门店', + dataIndex: ['store', 'name'], + ellipsis: true, + render: (v: string | undefined, row: CouponRedeemRecord) => + v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—', + }, + { + title: '本券分摊', + dataIndex: 'couponAmount', + width: 95, + render: (v: number | undefined, row: CouponRedeemRecord) => + `¥${Number(v ?? row.amount).toFixed(2)}`, + }, + { + title: '核销总额', + dataIndex: 'amount', + width: 90, + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '结算额', + dataIndex: 'settleAmount', + width: 90, + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '时间', + dataIndex: 'createdAt', + width: 150, + render: (v: string) => fmtTime(v), + }, + { + title: '操作', + width: 70, + render: (_: unknown, row: CouponRedeemRecord) => ( + + ), + }, + ]} + /> + + ) : ( + 暂无核销摘要 + )} + + )} + + + { + setRedeemDrawerOpen(false); + setRedeemDetail(null); + }} + destroyOnClose + > + {redeemDetailLoading ? ( + 加载中… + ) : redeemDetail ? ( - {String(detail.couponNo)} - - {String((detail.user as { userNo?: string } | undefined)?.userNo ?? '—')} + {String(redeemDetail.redeemNo ?? '—')} + + ¥{Number(redeemDetail.amount ?? 0).toFixed(2)} - - {String((detail.user as { phone?: string } | undefined)?.phone ?? '—')} + + ¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)} + + {fmtTime(String(redeemDetail.createdAt ?? ''))} + + {String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')} + {(redeemDetail.user as { phone?: string | null } | undefined)?.phone + ? ` / ${(redeemDetail.user as { phone?: string | null }).phone}` + : ''} + + + {String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')} + {(redeemDetail.store as { cityName?: string } | undefined)?.cityName + ? `(${(redeemDetail.store as { cityName?: string }).cityName})` + : ''} + + + {String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')} + + + {String( + (redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined) + ?.partnerAccount?.companyName ?? '—', + )} + + + {String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')} - {String((detail.order as { orderNo?: string } | null | undefined)?.orderNo ?? '—')} + {String( + (redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ?? + '—', + )} - ¥{String(detail.totalAmount)} - ¥{String(detail.balance)} - - {COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)} - - {String(detail.sourceProduct)} + {Array.isArray(redeemDetail.allocations) && + (redeemDetail.allocations as unknown[]).length > 0 ? ( + + {( + redeemDetail.allocations as Array<{ + couponNo?: string; + orderNo?: string | null; + amount?: number; + sortOrder?: number; + }> + ) + .map((a, idx) => { + const role = idx === 0 ? '主' : '次'; + return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${ + a.orderNo ? `(订单 ${a.orderNo})` : '' + }`; + }) + .join(';')} + + ) : null} + {redeemDetail.payout ? ( + + ¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)} + {' / '} + {String((redeemDetail.payout as { status?: string }).status ?? '—')} + + ) : null} + {redeemDetail.rating ? ( + + 服务 {(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '} + {(redeemDetail.rating as { envScore?: number }).envScore ?? '—'} 分 + + ) : null} - )} + ) : null} ); diff --git a/server/dukang-api/src/modules/ops/admin-benefit.service.ts b/server/dukang-api/src/modules/ops/admin-benefit.service.ts index a23e00a..b710efc 100644 --- a/server/dukang-api/src/modules/ops/admin-benefit.service.ts +++ b/server/dukang-api/src/modules/ops/admin-benefit.service.ts @@ -5,6 +5,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator' import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers'; import { mapBenefitLedgerCompat } from '../../common/compat/v31-compat'; import { BenefitService } from '../benefit/benefit.service'; +import { AdminRedeemService } from './admin-redeem.service'; import type { AdminBenefitCouponsQueryDto, AdminBenefitLedgersQueryDto } from './dto/admin-query.dto'; import type { AdminBenefitGrantDto } from './dto/admin-mutate.dto'; @@ -13,6 +14,7 @@ export class AdminBenefitService { constructor( private readonly prisma: PrismaService, private readonly benefitService: BenefitService, + private readonly adminRedeemService: AdminRedeemService, ) {} async listCoupons(query: AdminBenefitCouponsQueryDto) { @@ -45,16 +47,23 @@ export class AdminBenefitService { include: { user: { select: { id: true, userNo: true, phone: true, nickname: true } }, order: { select: { id: true, orderNo: true, status: true, payAmount: true } }, - redeemRecords: { orderBy: { createdAt: 'desc' }, take: 10, include: { store: { select: { id: true, name: true } } } }, }, }); if (!coupon) throw new NotFoundException('权益券不存在'); - const ledgers = await this.prisma.commonEvent.findMany({ - where: benefitLedgerWhere(undefined, id), - orderBy: { createdAt: 'desc' }, - take: 20, + const [ledgers, redeemTrace] = await Promise.all([ + this.prisma.commonEvent.findMany({ + where: benefitLedgerWhere(undefined, id), + orderBy: { createdAt: 'desc' }, + take: 20, + }), + this.adminRedeemService.buildCouponRedeemTrace(coupon), + ]); + return serializeBigInt({ + ...coupon, + ledgers, + redeemSummary: redeemTrace.redeemSummary, + redeemRecords: redeemTrace.redeemRecords, }); - return serializeBigInt({ ...coupon, ledgers }); } async voidCoupon(id: bigint) { diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts index f99f341..af795c4 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.service.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts @@ -11,6 +11,7 @@ import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.d import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto'; import { FulfillmentService } from '../fulfillment/fulfillment.service'; import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; +import { AdminRedeemService } from './admin-redeem.service'; @Injectable() export class AdminOrdersService { @@ -20,6 +21,7 @@ export class AdminOrdersService { private readonly xiaofeixiaService: AdminXiaofeixiaService, private readonly fulfillmentService: FulfillmentService, private readonly fulfillmentProviderService: FulfillmentProviderService, + private readonly adminRedeemService: AdminRedeemService, ) {} async list(query: AdminOrdersQueryDto) { @@ -106,49 +108,9 @@ export class AdminOrdersService { }); const coupon = order.benefitCoupon; - if (coupon) { - await this.ensureRedeemAllocationsForCoupon(coupon.id); - } - - const redeemRows = coupon - ? await this.prisma.redeemRecord.findMany({ - where: { - OR: [ - { couponId: coupon.id }, - { allocations: { some: { couponId: coupon.id } } }, - ], - }, - orderBy: { createdAt: 'desc' }, - include: { - store: { select: { id: true, name: true, cityName: true } }, - allocations: { - where: { couponId: coupon.id }, - select: { amount: true, sortOrder: true }, - }, - }, - }) - : []; - - const redeemRecords = redeemRows.map((r) => { - const couponAmount = - r.allocations[0] != null - ? Number(r.allocations[0].amount) - : r.couponId === coupon!.id - ? Number(r.amount) - : 0; - const isPrimary = r.couponId === coupon!.id; - return { - id: r.id, - redeemNo: r.redeemNo, - amount: Number(r.amount), - settleAmount: Number(r.settleAmount), - couponAmount, - role: isPrimary ? 'PRIMARY' : 'SECONDARY', - createdAt: r.createdAt, - store: r.store, - }; - }); - const redeemRecordSum = redeemRecords.reduce((sum, r) => sum + r.couponAmount, 0); + const redeemTrace = coupon + ? await this.adminRedeemService.buildCouponRedeemTrace(coupon) + : { redeemSummary: null, redeemRecords: [] }; const { benefitCoupon: _coupon, ...orderRest } = order; @@ -168,18 +130,8 @@ export class AdminOrdersService { }, ] : [], - redeemSummary: coupon - ? { - couponNo: coupon.couponNo, - totalAmount: Number(coupon.totalAmount), - usedAmount: Number(coupon.usedAmount), - balance: Number(coupon.balance), - status: coupon.status, - redeemCount: redeemRecords.length, - redeemRecordSum, - } - : null, - redeemRecords, + redeemSummary: redeemTrace.redeemSummary, + redeemRecords: redeemTrace.redeemRecords, }), ); } @@ -418,148 +370,4 @@ export class AdminOrdersService { }); await tx.order.deleteMany({ where: { id: { in: orderIds } } }); } - - /** - * 补齐历史核销分摊: - * 1) 待审核销 pending.allocationsJson → allocation 表 - * 2) 权益流水 REDEEM(次券)→ 按用户/门店/时间窗匹配核销单 - * 3) 仅主券、尚无分摊行的核销单 → 写入单行分摊(全额) - */ - private async ensureRedeemAllocationsForCoupon(couponId: bigint) { - const coupon = await this.prisma.benefitCoupon.findUnique({ - where: { id: couponId }, - select: { id: true, userId: true }, - }); - if (!coupon) return; - - const pendings = await this.prisma.redeemPendingRecord.findMany({ - where: { userId: coupon.userId, redeemRecordId: { not: null } }, - select: { redeemRecordId: true, allocationsJson: true }, - }); - - for (const pending of pendings) { - if (!pending.redeemRecordId) continue; - const allocs = this.parseAllocationsJson(pending.allocationsJson); - if (!allocs.some((a) => a.couponId === couponId.toString())) continue; - - const existing = await this.prisma.redeemRecordAllocation.count({ - where: { redeemRecordId: pending.redeemRecordId }, - }); - if (existing > 0) continue; - - await this.prisma.redeemRecordAllocation.createMany({ - data: allocs.map((a, index) => ({ - redeemRecordId: pending.redeemRecordId!, - couponId: BigInt(a.couponId), - amount: a.amount, - sortOrder: index, - })), - skipDuplicates: true, - }); - } - - const ledgers = await this.prisma.commonEvent.findMany({ - where: { - eventType: 'BENEFIT_LEDGER', - param1: 'REDEEM', - param2: couponId.toString(), - actorType: 'USER', - actorId: coupon.userId, - refType: 'STORE', - }, - select: { id: true, refId: true, amount1: true, createdAt: true }, - orderBy: { createdAt: 'asc' }, - }); - - for (const ledger of ledgers) { - if (ledger.refId == null || ledger.amount1 == null) continue; - const allocAmount = Math.abs(Number(ledger.amount1)); - if (!(allocAmount > 0)) continue; - - const already = await this.prisma.redeemRecordAllocation.findFirst({ - where: { - couponId, - amount: allocAmount, - redeemRecord: { - userId: coupon.userId, - storeId: ledger.refId, - createdAt: { - gte: new Date(ledger.createdAt.getTime() - 8000), - lte: new Date(ledger.createdAt.getTime() + 8000), - }, - }, - }, - select: { id: true }, - }); - if (already) continue; - - const candidates = await this.prisma.redeemRecord.findMany({ - where: { - userId: coupon.userId, - storeId: ledger.refId, - createdAt: { - gte: new Date(ledger.createdAt.getTime() - 8000), - lte: new Date(ledger.createdAt.getTime() + 8000), - }, - amount: { gte: allocAmount }, - allocations: { none: { couponId } }, - }, - orderBy: { createdAt: 'asc' }, - take: 5, - }); - if (!candidates.length) continue; - - // 优先挂到主券不是本券的核销单(跨券 FIFO 次券场景) - const target = - candidates.find((r) => r.couponId !== couponId) ?? - (candidates.length === 1 ? candidates[0] : null); - if (!target) continue; - - await this.prisma.redeemRecordAllocation.create({ - data: { - redeemRecordId: target.id, - couponId, - amount: allocAmount, - sortOrder: target.couponId === couponId ? 0 : 1, - }, - }).catch(() => { - /* unique 冲突忽略 */ - }); - } - - const primaryWithoutAlloc = await this.prisma.redeemRecord.findMany({ - where: { - couponId, - allocations: { none: {} }, - }, - select: { id: true, amount: true }, - }); - if (primaryWithoutAlloc.length) { - await this.prisma.redeemRecordAllocation.createMany({ - data: primaryWithoutAlloc.map((r) => ({ - redeemRecordId: r.id, - couponId, - amount: r.amount, - sortOrder: 0, - })), - skipDuplicates: true, - }); - } - } - - private parseAllocationsJson( - value: Prisma.JsonValue, - ): Array<{ couponId: string; amount: number }> { - if (!Array.isArray(value)) return []; - return value - .map((item) => { - if (!item || typeof item !== 'object') return null; - const row = item as { couponId?: unknown; amount?: unknown }; - const id = row.couponId != null ? String(row.couponId) : ''; - const amount = Number(row.amount); - if (!id || !Number.isFinite(amount) || amount <= 0) return null; - return { couponId: id, amount }; - }) - .filter((item): item is { couponId: string; amount: number } => !!item); - } } diff --git a/server/dukang-api/src/modules/ops/admin-redeem.service.ts b/server/dukang-api/src/modules/ops/admin-redeem.service.ts index bb58fad..847451c 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem.service.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem.service.ts @@ -6,6 +6,28 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator' import type { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto'; import type { UpdateDeliveryDto } from './dto/admin-mutate.dto'; +export type CouponRedeemTrace = { + redeemSummary: { + couponNo: string; + totalAmount: number; + usedAmount: number; + balance: number; + status: string; + redeemCount: number; + redeemRecordSum: number; + }; + redeemRecords: Array<{ + id: bigint; + redeemNo: string; + amount: number; + settleAmount: number; + couponAmount: number; + role: 'PRIMARY' | 'SECONDARY'; + createdAt: Date; + store: { id: bigint; name: string; cityName: string | null } | null; + }>; +}; + @Injectable() export class AdminRedeemService { constructor(private readonly prisma: PrismaService) {} @@ -90,6 +112,207 @@ export class AdminRedeemService { })), }); } + + /** 按权益券聚合核销追溯(含跨券 FIFO 次券) */ + async buildCouponRedeemTrace(coupon: { + id: bigint; + couponNo: string; + totalAmount: Prisma.Decimal | number; + usedAmount: Prisma.Decimal | number; + balance: Prisma.Decimal | number; + status: string; + }): Promise { + await this.ensureRedeemAllocationsForCoupon(coupon.id); + + const redeemRows = await this.prisma.redeemRecord.findMany({ + where: { + OR: [ + { couponId: coupon.id }, + { allocations: { some: { couponId: coupon.id } } }, + ], + }, + orderBy: { createdAt: 'desc' }, + include: { + store: { select: { id: true, name: true, cityName: true } }, + allocations: { + where: { couponId: coupon.id }, + select: { amount: true, sortOrder: true }, + }, + }, + }); + + const redeemRecords = redeemRows.map((r) => { + const couponAmount = + r.allocations[0] != null + ? Number(r.allocations[0].amount) + : r.couponId === coupon.id + ? Number(r.amount) + : 0; + return { + id: r.id, + redeemNo: r.redeemNo, + amount: Number(r.amount), + settleAmount: Number(r.settleAmount), + couponAmount, + role: (r.couponId === coupon.id ? 'PRIMARY' : 'SECONDARY') as 'PRIMARY' | 'SECONDARY', + createdAt: r.createdAt, + store: r.store, + }; + }); + const redeemRecordSum = redeemRecords.reduce((sum, r) => sum + r.couponAmount, 0); + + return { + redeemSummary: { + couponNo: coupon.couponNo, + totalAmount: Number(coupon.totalAmount), + usedAmount: Number(coupon.usedAmount), + balance: Number(coupon.balance), + status: coupon.status, + redeemCount: redeemRecords.length, + redeemRecordSum, + }, + redeemRecords, + }; + } + + private async ensureRedeemAllocationsForCoupon(couponId: bigint) { + const coupon = await this.prisma.benefitCoupon.findUnique({ + where: { id: couponId }, + select: { id: true, userId: true }, + }); + if (!coupon) return; + + const pendings = await this.prisma.redeemPendingRecord.findMany({ + where: { userId: coupon.userId, redeemRecordId: { not: null } }, + select: { redeemRecordId: true, allocationsJson: true }, + }); + + for (const pending of pendings) { + if (!pending.redeemRecordId) continue; + const allocs = this.parseAllocationsJson(pending.allocationsJson); + if (!allocs.some((a) => a.couponId === couponId.toString())) continue; + + const existing = await this.prisma.redeemRecordAllocation.count({ + where: { redeemRecordId: pending.redeemRecordId }, + }); + if (existing > 0) continue; + + await this.prisma.redeemRecordAllocation.createMany({ + data: allocs.map((a, index) => ({ + redeemRecordId: pending.redeemRecordId!, + couponId: BigInt(a.couponId), + amount: a.amount, + sortOrder: index, + })), + skipDuplicates: true, + }); + } + + const ledgers = await this.prisma.commonEvent.findMany({ + where: { + eventType: 'BENEFIT_LEDGER', + param1: 'REDEEM', + param2: couponId.toString(), + actorType: 'USER', + actorId: coupon.userId, + refType: 'STORE', + }, + select: { id: true, refId: true, amount1: true, createdAt: true }, + orderBy: { createdAt: 'asc' }, + }); + + for (const ledger of ledgers) { + if (ledger.refId == null || ledger.amount1 == null) continue; + const allocAmount = Math.abs(Number(ledger.amount1)); + if (!(allocAmount > 0)) continue; + + const already = await this.prisma.redeemRecordAllocation.findFirst({ + where: { + couponId, + amount: allocAmount, + redeemRecord: { + userId: coupon.userId, + storeId: ledger.refId, + createdAt: { + gte: new Date(ledger.createdAt.getTime() - 8000), + lte: new Date(ledger.createdAt.getTime() + 8000), + }, + }, + }, + select: { id: true }, + }); + if (already) continue; + + const candidates = await this.prisma.redeemRecord.findMany({ + where: { + userId: coupon.userId, + storeId: ledger.refId, + createdAt: { + gte: new Date(ledger.createdAt.getTime() - 8000), + lte: new Date(ledger.createdAt.getTime() + 8000), + }, + amount: { gte: allocAmount }, + allocations: { none: { couponId } }, + }, + orderBy: { createdAt: 'asc' }, + take: 5, + }); + if (!candidates.length) continue; + + const target = + candidates.find((r) => r.couponId !== couponId) ?? + (candidates.length === 1 ? candidates[0] : null); + if (!target) continue; + + await this.prisma.redeemRecordAllocation + .create({ + data: { + redeemRecordId: target.id, + couponId, + amount: allocAmount, + sortOrder: target.couponId === couponId ? 0 : 1, + }, + }) + .catch(() => { + /* unique 冲突忽略 */ + }); + } + + const primaryWithoutAlloc = await this.prisma.redeemRecord.findMany({ + where: { + couponId, + allocations: { none: {} }, + }, + select: { id: true, amount: true }, + }); + if (primaryWithoutAlloc.length) { + await this.prisma.redeemRecordAllocation.createMany({ + data: primaryWithoutAlloc.map((r) => ({ + redeemRecordId: r.id, + couponId, + amount: r.amount, + sortOrder: 0, + })), + skipDuplicates: true, + }); + } + } + + private parseAllocationsJson( + value: Prisma.JsonValue, + ): Array<{ couponId: string; amount: number }> { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (!item || typeof item !== 'object') return null; + const row = item as { couponId?: unknown; amount?: unknown }; + const id = row.couponId != null ? String(row.couponId) : ''; + const amount = Number(row.amount); + if (!id || !Number.isFinite(amount) || amount <= 0) return null; + return { couponId: id, amount }; + }) + .filter((item): item is { couponId: string; amount: number } => !!item); + } } @Injectable()