feat(supply-chain): 店铺账号授权提醒推送(正常+未授权店铺推送给运营负责人,离职顺延)
This commit is contained in:
parent
2528229c08
commit
12feabd17f
@ -0,0 +1,27 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { storeAccountAuthorizationNoticeService } from '@/server/services/store-account-authorization-notice.service'
|
||||
|
||||
const schema = z.object({
|
||||
ids: z.array(z.string().trim().min(1)).min(1).max(500),
|
||||
testRecipientName: z.string().trim().max(60).optional(),
|
||||
}).strict()
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const parsed = schema.safeParse(await request.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '授权提醒推送内容不正确', 400, ctx.requestId)
|
||||
}
|
||||
return jsonSuccess(
|
||||
await storeAccountAuthorizationNoticeService.notifyUnauthorizedStores({
|
||||
...parsed.data,
|
||||
user: ctx.user,
|
||||
}),
|
||||
200,
|
||||
ctx.requestId,
|
||||
)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -0,0 +1,290 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import {
|
||||
formatStoreAccountAuthorizationStatus,
|
||||
formatStoreAccountShopState,
|
||||
type StoreAccountAuthorizationNoticeItemStatus,
|
||||
type StoreAccountAuthorizationNoticeResult,
|
||||
type StoreAccountAuthorizationStatus,
|
||||
} from '@/lib/store-account-management'
|
||||
|
||||
type NoticePreviewRow = {
|
||||
id: string
|
||||
storeName: string
|
||||
shopState: number | null
|
||||
authorizationStatus: StoreAccountAuthorizationStatus | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
count: number
|
||||
rows: NoticePreviewRow[]
|
||||
offPageCount: number
|
||||
onClose: (pushed: boolean) => void
|
||||
onPush: (testRecipientName: string) => Promise<StoreAccountAuthorizationNoticeResult>
|
||||
}
|
||||
|
||||
const RESULT_STATUS_LABELS: Record<StoreAccountAuthorizationNoticeItemStatus, string> = {
|
||||
sent: '已推送',
|
||||
skipped: '已跳过',
|
||||
failed: '失败',
|
||||
}
|
||||
|
||||
export function StoreAccountAuthorizationNoticeDialog({
|
||||
count,
|
||||
rows,
|
||||
offPageCount,
|
||||
onClose,
|
||||
onPush,
|
||||
}: Props) {
|
||||
const [testRecipientName, setTestRecipientName] = useState('')
|
||||
const [pushing, setPushing] = useState(false)
|
||||
const [result, setResult] = useState<StoreAccountAuthorizationNoticeResult | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const confirm = async () => {
|
||||
setPushing(true)
|
||||
setError('')
|
||||
try {
|
||||
setResult(await onPush(testRecipientName.trim()))
|
||||
} catch (pushError) {
|
||||
setError(pushError instanceof Error ? pushError.message : '推送失败')
|
||||
} finally {
|
||||
setPushing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => onClose(Boolean(result))
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget && !pushing) close()
|
||||
}}
|
||||
>
|
||||
<div className="modal notice-modal">
|
||||
<header>
|
||||
<div>
|
||||
<h2>推送店铺授权提醒</h2>
|
||||
<p>提醒店铺负责人尽快完成店铺授权。</p>
|
||||
</div>
|
||||
<button type="button" onClick={close} aria-label="关闭" disabled={pushing}>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{result ? (
|
||||
<>
|
||||
<p className="notice-summary">
|
||||
共 {result.summary.total} 条:已推送 {result.summary.sent}、已跳过{' '}
|
||||
{result.summary.skipped}、失败 {result.summary.failed}。
|
||||
</p>
|
||||
<div className="notice-result-list">
|
||||
{result.items.map((item, index) => (
|
||||
<p
|
||||
key={`${item.storeName}-${index}`}
|
||||
title={item.reason || undefined}
|
||||
>
|
||||
<span className={`notice-status ${item.status}`}>
|
||||
{RESULT_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
<span className="notice-store">{item.storeName || '(账号已不存在)'}</span>
|
||||
{item.recipientName && (
|
||||
<span className="notice-recipient">
|
||||
收件人:{item.recipientName}({item.recipientRole})
|
||||
</span>
|
||||
)}
|
||||
{item.reason && <span className="notice-reason">{item.reason}</span>}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{error && <p className="notice-error">{error}</p>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" onClick={close}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="notice-rules">
|
||||
仅推送「店铺状态=正常 且 授权状态=未授权」的账号;正常推送给店铺运营负责人,
|
||||
负责人离职或无法接收时按 店铺运营部门负责人 → 店铺运营总监 顺延。
|
||||
</p>
|
||||
<div className="notice-preview-list">
|
||||
{rows.length === 0 ? (
|
||||
<p className="notice-preview-empty">所选账号均不在当前页。</p>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<p key={row.id}>
|
||||
{row.storeName || '(未填店铺名称)'} ·{' '}
|
||||
{formatStoreAccountShopState(row.shopState)} ·{' '}
|
||||
{formatStoreAccountAuthorizationStatus(row.authorizationStatus)}
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{offPageCount > 0 && (
|
||||
<p className="notice-offpage-hint">
|
||||
另有 {offPageCount} 条所选账号不在当前页,将一并按规则处理。
|
||||
</p>
|
||||
)}
|
||||
<label className="notice-test-recipient">
|
||||
测试收件人(可选)
|
||||
<input
|
||||
value={testRecipientName}
|
||||
onChange={(event) => setTestRecipientName(event.target.value)}
|
||||
placeholder="填写后本次全部推送给该人员,例如:徐霞客"
|
||||
maxLength={60}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="notice-error">{error}</p>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" onClick={close} disabled={pushing}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
disabled={pushing || count === 0}
|
||||
onClick={() => void confirm()}
|
||||
>
|
||||
{pushing ? '推送中...' : `确认推送(${count})`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.notice-modal {
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
}
|
||||
header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
header p {
|
||||
margin: 5px 0 0;
|
||||
color: #71717a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.notice-rules {
|
||||
margin: 0 0 10px;
|
||||
color: #52525b;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.notice-preview-list {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.notice-preview-list p {
|
||||
height: 28px;
|
||||
margin: 0;
|
||||
color: #3f3f46;
|
||||
font-size: 12px;
|
||||
line-height: 28px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.notice-preview-empty {
|
||||
color: #a1a1aa;
|
||||
text-align: center;
|
||||
}
|
||||
.notice-offpage-hint {
|
||||
margin: 8px 0 0;
|
||||
color: #71717a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.notice-test-recipient {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
color: #52525b;
|
||||
font-size: 12px;
|
||||
}
|
||||
.notice-test-recipient input {
|
||||
height: 36px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
outline: 0;
|
||||
color: #27272a;
|
||||
font-size: 13px;
|
||||
}
|
||||
.notice-test-recipient input:focus {
|
||||
border-color: #52525b;
|
||||
}
|
||||
.notice-summary {
|
||||
margin: 0;
|
||||
color: #27272a;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.notice-result-list {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
margin-top: 10px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.notice-result-list p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
margin: 0;
|
||||
color: #3f3f46;
|
||||
font-size: 12px;
|
||||
}
|
||||
.notice-status {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 600;
|
||||
}
|
||||
.notice-status.sent {
|
||||
color: #15803d;
|
||||
}
|
||||
.notice-status.skipped {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
.notice-status.failed {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.notice-store {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.notice-recipient {
|
||||
flex: 0 0 auto;
|
||||
color: #52525b;
|
||||
}
|
||||
.notice-reason {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
color: #a1a1aa;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.notice-error {
|
||||
margin: 10px 0 0;
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
BellRing,
|
||||
ChevronDown,
|
||||
FileDown,
|
||||
History,
|
||||
@ -29,6 +30,7 @@ import type {
|
||||
StoreAccountPage,
|
||||
StoreAccountRow,
|
||||
StoreAccountFilters,
|
||||
StoreAccountAuthorizationNoticeResult,
|
||||
} from "@/lib/store-account-management";
|
||||
import type { ColumnHeaderFilterSelection } from "@/lib/column-header-filters";
|
||||
import {
|
||||
@ -47,6 +49,7 @@ import {
|
||||
import { ColumnHeaderFilter } from "@/components/common/ColumnHeaderFilter";
|
||||
import { StoreAccountBatchUpdateDialog } from "./StoreAccountBatchUpdateDialog";
|
||||
import { StoreAccountBatchDeleteDialog } from "./StoreAccountBatchDeleteDialog";
|
||||
import { StoreAccountAuthorizationNoticeDialog } from "./StoreAccountAuthorizationNoticeDialog";
|
||||
import { StoreAccountImportProgress } from "./StoreAccountImportProgress";
|
||||
import { StoreAccountLogDialog } from "./StoreAccountLogDialog";
|
||||
import { StoreAccountPermissionDialog } from "./StoreAccountPermissionDialog";
|
||||
@ -185,6 +188,7 @@ export function StoreAccountManagementView({
|
||||
const [permissionsOpen, setPermissionsOpen] = useState(false);
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [noticeOpen, setNoticeOpen] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [batchField, setBatchField] =
|
||||
useState<StoreAccountBatchEditableFieldKey>("operationOwner");
|
||||
@ -421,6 +425,21 @@ export function StoreAccountManagementView({
|
||||
);
|
||||
};
|
||||
|
||||
const pushAuthorizationNotice = async (testRecipientName: string) => {
|
||||
const body: Record<string, unknown> = { ids: selectedIds };
|
||||
if (testRecipientName) body.testRecipientName = testRecipientName;
|
||||
return (await readResponse(
|
||||
await fetch(
|
||||
"/api/supply-chain/store-account-management/authorization-notice",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
),
|
||||
)) as StoreAccountAuthorizationNoticeResult;
|
||||
};
|
||||
|
||||
const openLogs = async (row: StoreAccountRow) => {
|
||||
const result = await readResponse(
|
||||
await fetch(
|
||||
@ -469,6 +488,15 @@ export function StoreAccountManagementView({
|
||||
<Trash2 size={15} /> 批量删除
|
||||
{selectedIds.length > 0 ? `(${selectedIds.length})` : ""}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedIds.length === 0}
|
||||
onClick={() => setNoticeOpen(true)}
|
||||
title="提醒店铺负责人去完成授权"
|
||||
>
|
||||
<BellRing size={15} /> 推送授权提醒
|
||||
{selectedIds.length > 0 ? `(${selectedIds.length})` : ""}
|
||||
</button>
|
||||
<button type="button" onClick={() => void downloadTemplate()}>
|
||||
<FileDown size={15} /> 下载模板
|
||||
</button>
|
||||
@ -893,6 +921,23 @@ export function StoreAccountManagementView({
|
||||
onConfirm={batchDelete}
|
||||
/>
|
||||
)}
|
||||
{noticeOpen && (
|
||||
<StoreAccountAuthorizationNoticeDialog
|
||||
count={selectedIds.length}
|
||||
rows={selectedRowsOnPage.map((row) => ({
|
||||
id: row.id,
|
||||
storeName: row.storeName,
|
||||
shopState: row.shopState,
|
||||
authorizationStatus: row.authorizationStatus,
|
||||
}))}
|
||||
offPageCount={offPageSelectedCount}
|
||||
onClose={(pushed) => {
|
||||
setNoticeOpen(false);
|
||||
if (pushed) setSelectedIds([]);
|
||||
}}
|
||||
onPush={pushAuthorizationNotice}
|
||||
/>
|
||||
)}
|
||||
<StoreAccountImportProgress
|
||||
state={storeAccountImport.state}
|
||||
onDismiss={storeAccountImport.dismiss}
|
||||
|
||||
@ -342,6 +342,29 @@ export function formatStoreAccountAuthorizationStatus(
|
||||
return "-";
|
||||
}
|
||||
|
||||
export type StoreAccountAuthorizationNoticeItemStatus =
|
||||
| "sent"
|
||||
| "skipped"
|
||||
| "failed";
|
||||
|
||||
export type StoreAccountAuthorizationNoticeItem = {
|
||||
storeName: string;
|
||||
recipientName: string;
|
||||
recipientRole: string;
|
||||
status: StoreAccountAuthorizationNoticeItemStatus;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type StoreAccountAuthorizationNoticeResult = {
|
||||
items: StoreAccountAuthorizationNoticeItem[];
|
||||
summary: {
|
||||
total: number;
|
||||
sent: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function getStoreAccountLogOperationLabel(operation: string) {
|
||||
return STORE_ACCOUNT_LOG_OPERATION_LABELS[operation] ?? "其他操作";
|
||||
}
|
||||
|
||||
@ -0,0 +1,261 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
const mockGetUserAccess = jest.fn()
|
||||
|
||||
jest.mock('@/server/services/supply-chain-permission-settings.service', () => ({
|
||||
supplyChainPermissionSettingsService: {
|
||||
getUserAccess: (...args: unknown[]) => mockGetUserAccess(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/server/repositories/store-account-management.repository', () => ({
|
||||
storeAccountManagementRepository: { listByIds: jest.fn() },
|
||||
}))
|
||||
|
||||
jest.mock('@/server/repositories/store-account-notification.repository', () => ({
|
||||
storeAccountNotificationRepository: { findUsersByNames: jest.fn() },
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/notification-delivery.service', () => ({
|
||||
notificationDeliveryService: { sendToEmployee: jest.fn(), recordUndeliverable: jest.fn() },
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/store-account-authorization.service', () => ({
|
||||
storeAccountAuthorizationService: { getStatusMap: jest.fn() },
|
||||
}))
|
||||
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import type { StoreAccountRecord } from '@/server/repositories/store-account-management.repository'
|
||||
import type { StoreAccountNotificationRecipient } from '@/server/repositories/store-account-notification.repository'
|
||||
import {
|
||||
StoreAccountAuthorizationNoticeService,
|
||||
STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT,
|
||||
} from '../store-account-authorization-notice.service'
|
||||
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
email: 'xuxiake@example.com',
|
||||
name: '徐霞客',
|
||||
role: 'ADMIN' as const,
|
||||
status: 'ACTIVE' as const,
|
||||
} as AuthUser
|
||||
|
||||
const now = new Date('2026-10-08T04:00:00.000Z')
|
||||
|
||||
function buildRecord(overrides: Partial<StoreAccountRecord> = {}): StoreAccountRecord {
|
||||
return {
|
||||
id: 'account-1',
|
||||
storeName: '拼多多-测试店铺',
|
||||
operationOwner: '张三',
|
||||
operationDepartmentHead: '李四',
|
||||
operationDirector: '王五',
|
||||
profitCenter: '',
|
||||
accountType: 'MAIN',
|
||||
account: '测试账号',
|
||||
passwordCiphertext: 'ciphertext',
|
||||
boundPhone: '',
|
||||
verificationContact: '',
|
||||
jstOrderService: '',
|
||||
accountGroupName: '',
|
||||
shopState: 0,
|
||||
subject: '',
|
||||
boundBankAccountNumberCiphertext: '',
|
||||
createdAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecipient(overrides: Partial<StoreAccountNotificationRecipient> = {}): StoreAccountNotificationRecipient {
|
||||
return {
|
||||
id: 'recipient-1',
|
||||
name: '张三',
|
||||
email: 'zhangsan@example.com',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: [],
|
||||
dingtalkUserId: 'ding-1',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function setupService(records: StoreAccountRecord[], recipients: StoreAccountNotificationRecipient[], options?: {
|
||||
statusByStoreName?: Map<string, string | null>
|
||||
sendError?: Error
|
||||
}) {
|
||||
const listByIds = jest.fn().mockResolvedValue(records)
|
||||
const findUsersByNames = jest.fn().mockResolvedValue(recipients)
|
||||
const getStatusMap = jest.fn().mockResolvedValue(
|
||||
options?.statusByStoreName ?? new Map([['拼多多-测试店铺', 'UNAUTHORIZED']]),
|
||||
)
|
||||
const sendToEmployee = jest.fn().mockImplementation(() => {
|
||||
if (options?.sendError) throw options.sendError
|
||||
return { id: 'delivery-1' }
|
||||
})
|
||||
const recordUndeliverable = jest.fn().mockResolvedValue({ id: 'audit-1' })
|
||||
const service = new StoreAccountAuthorizationNoticeService(
|
||||
{ listByIds },
|
||||
{ findUsersByNames },
|
||||
{ getStatusMap },
|
||||
{ sendToEmployee, recordUndeliverable },
|
||||
)
|
||||
return { service, listByIds, findUsersByNames, getStatusMap, sendToEmployee, recordUndeliverable }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockGetUserAccess.mockResolvedValue({ view: true, edit: true })
|
||||
})
|
||||
|
||||
describe('store account authorization notice permissions', () => {
|
||||
it('rejects users without store account edit access', async () => {
|
||||
mockGetUserAccess.mockResolvedValue({ view: true, edit: false })
|
||||
const { service, sendToEmployee } = setupService([buildRecord()], [])
|
||||
await expect(
|
||||
service.notifyUnauthorizedStores({ ids: ['account-1'], user }),
|
||||
).rejects.toThrow('当前账号没有触发店铺授权提醒推送的权限')
|
||||
expect(sendToEmployee).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('store account authorization notice validation', () => {
|
||||
it('skips accounts whose shop state is not normal', async () => {
|
||||
const record = buildRecord({ id: 'account-1', shopState: 6 })
|
||||
const { service, sendToEmployee } = setupService([record], [])
|
||||
const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now })
|
||||
expect(result.items[0]).toMatchObject({ status: 'skipped' })
|
||||
expect(result.items[0].reason).toContain('店铺状态不是正常')
|
||||
expect(sendToEmployee).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips accounts whose authorization status is not unauthorized', async () => {
|
||||
const record = buildRecord()
|
||||
const { service } = setupService([record], [], {
|
||||
statusByStoreName: new Map([['拼多多-测试店铺', 'AUTHORIZED']]),
|
||||
})
|
||||
const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now })
|
||||
expect(result.items[0]).toMatchObject({ status: 'skipped' })
|
||||
expect(result.items[0].reason).toContain('授权状态不是未授权')
|
||||
})
|
||||
|
||||
it('skips missing accounts', async () => {
|
||||
const { service } = setupService([], [])
|
||||
const result = await service.notifyUnauthorizedStores({ ids: ['missing'], user, now })
|
||||
expect(result.items[0]).toMatchObject({ status: 'skipped', reason: '店铺账号不存在或已被删除' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('store account authorization notice recipients', () => {
|
||||
it('pushes to the operation owner with text-only content', async () => {
|
||||
const record = buildRecord()
|
||||
const owner = buildRecipient()
|
||||
const { service, sendToEmployee } = setupService([record], [owner])
|
||||
const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now })
|
||||
expect(result.summary).toEqual({ total: 1, sent: 1, skipped: 0, failed: 0 })
|
||||
expect(result.items[0]).toMatchObject({
|
||||
storeName: '拼多多-测试店铺',
|
||||
recipientName: '张三',
|
||||
recipientRole: '店铺运营负责人',
|
||||
status: 'sent',
|
||||
})
|
||||
expect(sendToEmployee).toHaveBeenCalledTimes(1)
|
||||
expect(sendToEmployee).toHaveBeenCalledWith(expect.objectContaining({
|
||||
businessEvent: STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT,
|
||||
businessRecordId: 'account-1',
|
||||
recipientUserId: 'recipient-1',
|
||||
title: '店铺授权提醒',
|
||||
content: '店铺名称:拼多多-测试店铺\n\n授权状态:未授权',
|
||||
idempotencyKey: `${STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT}:account-1:2026-10-08`,
|
||||
retryExisting: false,
|
||||
}))
|
||||
})
|
||||
|
||||
it('falls back to the department head when the owner has left', async () => {
|
||||
const record = buildRecord()
|
||||
const departedOwner = buildRecipient({ id: 'owner-1', name: '张三', status: 'INACTIVE' })
|
||||
const head = buildRecipient({ id: 'head-1', name: '李四', dingtalkUserId: 'ding-2' })
|
||||
const { service, sendToEmployee } = setupService([record], [departedOwner, head])
|
||||
const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now })
|
||||
expect(result.items[0]).toMatchObject({
|
||||
recipientName: '李四',
|
||||
recipientRole: '店铺运营部门负责人',
|
||||
status: 'sent',
|
||||
})
|
||||
expect(sendToEmployee).toHaveBeenCalledWith(expect.objectContaining({ recipientUserId: 'head-1' }))
|
||||
})
|
||||
|
||||
it('falls back to the director when owner and head are both unavailable', async () => {
|
||||
const record = buildRecord({ operationOwner: '张三', operationDepartmentHead: '李四' })
|
||||
const departedOwner = buildRecipient({ id: 'owner-1', name: '张三', status: 'INACTIVE' })
|
||||
const unboundHead = buildRecipient({ id: 'head-1', name: '李四', dingtalkUserId: null })
|
||||
const director = buildRecipient({ id: 'director-1', name: '王五', dingtalkUserId: 'ding-3' })
|
||||
const { service, sendToEmployee } = setupService(
|
||||
[record],
|
||||
[departedOwner, unboundHead, director],
|
||||
)
|
||||
const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now })
|
||||
expect(result.items[0]).toMatchObject({
|
||||
recipientName: '王五',
|
||||
recipientRole: '店铺运营总监',
|
||||
status: 'sent',
|
||||
})
|
||||
expect(sendToEmployee).toHaveBeenCalledWith(expect.objectContaining({ recipientUserId: 'director-1' }))
|
||||
})
|
||||
|
||||
it('marks failure with an audit record when nobody in the chain can receive', async () => {
|
||||
const record = buildRecord()
|
||||
const { service, sendToEmployee, recordUndeliverable } = setupService([record], [])
|
||||
const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now })
|
||||
expect(result.items[0]).toMatchObject({ status: 'failed' })
|
||||
expect(result.items[0].reason).toContain('均无法接收')
|
||||
expect(sendToEmployee).not.toHaveBeenCalled()
|
||||
expect(recordUndeliverable).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorCode: 'RECIPIENT_NOT_FOUND',
|
||||
}))
|
||||
})
|
||||
|
||||
it('reports delivery failures per account without stopping the batch', async () => {
|
||||
const first = buildRecord({ id: 'account-1', storeName: '拼多多-测试店铺' })
|
||||
const second = buildRecord({ id: 'account-2', storeName: '拼多多-另一家店', operationOwner: '李四' })
|
||||
const owner = buildRecipient()
|
||||
const head = buildRecipient({ id: 'head-1', name: '李四' })
|
||||
const { service } = setupService([first, second], [owner, head], {
|
||||
statusByStoreName: new Map([
|
||||
['拼多多-测试店铺', 'UNAUTHORIZED'],
|
||||
['拼多多-另一家店', 'UNAUTHORIZED'],
|
||||
]),
|
||||
sendError: new Error('通知中心调用失败'),
|
||||
})
|
||||
const result = await service.notifyUnauthorizedStores({ ids: ['account-1', 'account-2'], user, now })
|
||||
expect(result.summary).toEqual({ total: 2, sent: 0, skipped: 0, failed: 2 })
|
||||
expect(result.items.every((item) => item.reason === '通知中心调用失败')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('store account authorization notice test recipient', () => {
|
||||
it('pushes everything to the test recipient with a dedicated idempotency key', async () => {
|
||||
const record = buildRecord()
|
||||
const tester = buildRecipient({ id: 'tester-1', name: '徐霞客' })
|
||||
const { service, sendToEmployee, findUsersByNames } = setupService([record], [tester])
|
||||
const result = await service.notifyUnauthorizedStores({
|
||||
ids: ['account-1'],
|
||||
testRecipientName: '徐霞客',
|
||||
user,
|
||||
now,
|
||||
})
|
||||
expect(result.items[0]).toMatchObject({ recipientName: '徐霞客', recipientRole: '测试收件人', status: 'sent' })
|
||||
expect(findUsersByNames).toHaveBeenCalledWith(['徐霞客'])
|
||||
expect(sendToEmployee).toHaveBeenCalledWith(expect.objectContaining({
|
||||
recipientUserId: 'tester-1',
|
||||
idempotencyKey: `${STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT}:account-1:2026-10-08:test:tester-1`,
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects test recipients that cannot receive notifications', async () => {
|
||||
const record = buildRecord()
|
||||
const { service, sendToEmployee } = setupService([record], [])
|
||||
await expect(
|
||||
service.notifyUnauthorizedStores({ ids: ['account-1'], testRecipientName: '不存在的人', user, now }),
|
||||
).rejects.toThrow('不存在、已离职或未绑定钉钉')
|
||||
expect(sendToEmployee).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,265 @@
|
||||
import 'server-only'
|
||||
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import {
|
||||
formatStoreAccountAuthorizationStatus,
|
||||
formatStoreAccountShopState,
|
||||
type StoreAccountAuthorizationNoticeItem,
|
||||
type StoreAccountAuthorizationNoticeResult,
|
||||
} from '@/lib/store-account-management'
|
||||
import { supplyChainPermissionSettingsService } from './supply-chain-permission-settings.service'
|
||||
import {
|
||||
storeAccountManagementRepository,
|
||||
type StoreAccountManagementRepository,
|
||||
type StoreAccountRecord,
|
||||
} from '@/server/repositories/store-account-management.repository'
|
||||
import {
|
||||
storeAccountNotificationRepository,
|
||||
type StoreAccountNotificationRecipient,
|
||||
} from '@/server/repositories/store-account-notification.repository'
|
||||
import {
|
||||
notificationDeliveryService,
|
||||
type NotificationDeliveryService,
|
||||
} from '@/server/services/notification-delivery.service'
|
||||
import {
|
||||
storeAccountAuthorizationService,
|
||||
type StoreAccountAuthorizationReader,
|
||||
} from '@/server/services/store-account-authorization.service'
|
||||
|
||||
export const STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT = 'store_account_authorization_notice'
|
||||
|
||||
// 推送目标解析顺序:正常只推店铺运营负责人;负责人离职或无在职内部账号时按部门负责人、运营总监顺延。
|
||||
const RECIPIENT_CHAIN = [
|
||||
{ key: 'operationOwner', label: '店铺运营负责人' },
|
||||
{ key: 'operationDepartmentHead', label: '店铺运营部门负责人' },
|
||||
{ key: 'operationDirector', label: '店铺运营总监' },
|
||||
] as const
|
||||
|
||||
const EXTERNAL_ACCOUNT_ROLES = new Set(['SUPPLIER', 'EXTERNAL_INTAKE'])
|
||||
|
||||
type AccountRecordReader = Pick<StoreAccountManagementRepository, 'listByIds'>
|
||||
type AccountUserReader = Pick<typeof storeAccountNotificationRepository, 'findUsersByNames'>
|
||||
type AccountDelivery = Pick<NotificationDeliveryService, 'sendToEmployee' | 'recordUndeliverable'>
|
||||
|
||||
function isDeliverableRecipient(recipient: StoreAccountNotificationRecipient | undefined) {
|
||||
return Boolean(
|
||||
recipient
|
||||
&& recipient.status === 'ACTIVE'
|
||||
&& !recipient.businessRoles.some((role) => EXTERNAL_ACCOUNT_ROLES.has(role))
|
||||
&& recipient.dingtalkUserId?.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
function formatDayBucket(now: Date) {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
}
|
||||
|
||||
function formatNoticeContent(storeName: string) {
|
||||
// 钉钉正文按 Markdown 渲染,字段间用空行分隔,保证各占一行。
|
||||
return `店铺名称:${storeName.trim() || '未填写'}\n\n授权状态:未授权`
|
||||
}
|
||||
|
||||
export class StoreAccountAuthorizationNoticeService {
|
||||
constructor(
|
||||
private readonly repository: AccountRecordReader = storeAccountManagementRepository,
|
||||
private readonly userReader: AccountUserReader = storeAccountNotificationRepository,
|
||||
private readonly authorizationReader: StoreAccountAuthorizationReader = storeAccountAuthorizationService,
|
||||
private readonly delivery: AccountDelivery = notificationDeliveryService,
|
||||
) {}
|
||||
|
||||
async notifyUnauthorizedStores(params: {
|
||||
ids: string[]
|
||||
testRecipientName?: string
|
||||
user: AuthUser
|
||||
now?: Date
|
||||
}): Promise<StoreAccountAuthorizationNoticeResult> {
|
||||
const access = await supplyChainPermissionSettingsService.getUserAccess(
|
||||
params.user,
|
||||
'storeAccountManagement',
|
||||
)
|
||||
if (!access.edit) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有触发店铺授权提醒推送的权限')
|
||||
}
|
||||
|
||||
const now = params.now ?? new Date()
|
||||
const day = formatDayBucket(now)
|
||||
const ids = [...new Set(params.ids.map((id) => id.trim()).filter(Boolean))]
|
||||
const records = ids.length > 0 ? await this.repository.listByIds(ids) : []
|
||||
const recordById = new Map(records.map((record) => [record.id, record]))
|
||||
const authorizationStatusByStoreName = await this.authorizationReader.getStatusMap(
|
||||
records.map((record) => record.storeName),
|
||||
)
|
||||
|
||||
const testRecipient = await this.resolveTestRecipient(params.testRecipientName)
|
||||
const deliverableUserByName = testRecipient
|
||||
? new Map<string, StoreAccountNotificationRecipient>()
|
||||
: await this.loadDeliverableUsers(records)
|
||||
|
||||
const items: StoreAccountAuthorizationNoticeItem[] = []
|
||||
for (const id of ids) {
|
||||
const record = recordById.get(id)
|
||||
if (!record) {
|
||||
items.push({
|
||||
storeName: '',
|
||||
recipientName: '',
|
||||
recipientRole: '',
|
||||
status: 'skipped',
|
||||
reason: '店铺账号不存在或已被删除',
|
||||
})
|
||||
continue
|
||||
}
|
||||
const status = authorizationStatusByStoreName.get(record.storeName.trim()) ?? null
|
||||
if (record.shopState !== 0) {
|
||||
items.push({
|
||||
storeName: record.storeName,
|
||||
recipientName: '',
|
||||
recipientRole: '',
|
||||
status: 'skipped',
|
||||
reason: `店铺状态不是正常(${formatStoreAccountShopState(record.shopState)})`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (status !== 'UNAUTHORIZED') {
|
||||
items.push({
|
||||
storeName: record.storeName,
|
||||
recipientName: '',
|
||||
recipientRole: '',
|
||||
status: 'skipped',
|
||||
reason: `授权状态不是未授权(${formatStoreAccountAuthorizationStatus(status)})`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const target = testRecipient
|
||||
? { recipient: testRecipient, role: '测试收件人' }
|
||||
: this.resolveChainRecipient(record, deliverableUserByName)
|
||||
if (!target) {
|
||||
await this.recordNoRecipientAudit(record, day, params.user)
|
||||
items.push({
|
||||
storeName: record.storeName,
|
||||
recipientName: '',
|
||||
recipientRole: '',
|
||||
status: 'failed',
|
||||
reason: '店铺运营负责人、部门负责人、运营总监均无法接收(离职或未绑定钉钉)',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const idempotencyKey = [
|
||||
STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT,
|
||||
record.id,
|
||||
day,
|
||||
...(testRecipient ? ['test', testRecipient.id] : []),
|
||||
].join(':')
|
||||
try {
|
||||
await this.delivery.sendToEmployee({
|
||||
idempotencyKey,
|
||||
businessEvent: STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT,
|
||||
businessRecordId: record.id,
|
||||
source: 'USER',
|
||||
initiatedById: params.user.id,
|
||||
initiatedByName: params.user.name,
|
||||
recipientUserId: target.recipient.id,
|
||||
title: '店铺授权提醒',
|
||||
content: formatNoticeContent(record.storeName),
|
||||
retryExisting: false,
|
||||
})
|
||||
items.push({
|
||||
storeName: record.storeName,
|
||||
recipientName: target.recipient.name,
|
||||
recipientRole: target.role,
|
||||
status: 'sent',
|
||||
reason: '',
|
||||
})
|
||||
} catch (error) {
|
||||
items.push({
|
||||
storeName: record.storeName,
|
||||
recipientName: target.recipient.name,
|
||||
recipientRole: target.role,
|
||||
status: 'failed',
|
||||
reason: error instanceof Error ? error.message : '通知投递失败',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const summary = { total: items.length, sent: 0, skipped: 0, failed: 0 }
|
||||
for (const item of items) summary[item.status] += 1
|
||||
return { items, summary }
|
||||
}
|
||||
|
||||
private async resolveTestRecipient(name?: string) {
|
||||
const normalized = (name ?? '').trim()
|
||||
if (!normalized) return null
|
||||
const users = await this.userReader.findUsersByNames([normalized])
|
||||
const matched = users.find((user) => user.name === normalized && isDeliverableRecipient(user))
|
||||
if (!matched) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
`测试收件人「${normalized}」不存在、已离职或未绑定钉钉,无法推送`,
|
||||
)
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
private async loadDeliverableUsers(records: StoreAccountRecord[]) {
|
||||
const names = [
|
||||
...new Set(
|
||||
records
|
||||
.flatMap((record) => RECIPIENT_CHAIN.map(({ key }) => record[key].trim()))
|
||||
.filter(Boolean),
|
||||
),
|
||||
]
|
||||
const users = names.length > 0 ? await this.userReader.findUsersByNames(names) : []
|
||||
const deliverableByName = new Map<string, StoreAccountNotificationRecipient>()
|
||||
for (const user of users) {
|
||||
if (!isDeliverableRecipient(user)) continue
|
||||
if (!deliverableByName.has(user.name)) deliverableByName.set(user.name, user)
|
||||
}
|
||||
return deliverableByName
|
||||
}
|
||||
|
||||
private resolveChainRecipient(
|
||||
record: StoreAccountRecord,
|
||||
deliverableUserByName: Map<string, StoreAccountNotificationRecipient>,
|
||||
) {
|
||||
for (const { key, label } of RECIPIENT_CHAIN) {
|
||||
const name = record[key].trim()
|
||||
if (!name) continue
|
||||
const recipient = deliverableUserByName.get(name)
|
||||
if (recipient) return { recipient, role: label }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async recordNoRecipientAudit(record: StoreAccountRecord, day: string, user: AuthUser) {
|
||||
const chainNames = RECIPIENT_CHAIN.map(({ key }) => record[key].trim()).filter(Boolean)
|
||||
const auditId = `missing:store-account-auth-notice:${record.id}`
|
||||
try {
|
||||
await this.delivery.recordUndeliverable({
|
||||
// 用独立后缀,避免占用正常投递的幂等键,负责人补齐后当天仍可正常推送。
|
||||
idempotencyKey: `${STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT}:${record.id}:${day}:no-recipient`,
|
||||
businessEvent: STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT,
|
||||
businessRecordId: record.id,
|
||||
source: 'USER',
|
||||
initiatedById: user.id,
|
||||
initiatedByName: user.name,
|
||||
recipient: {
|
||||
id: auditId,
|
||||
name: chainNames[chainNames.length - 1] ?? '未维护负责人',
|
||||
email: '',
|
||||
dingtalkUserId: '',
|
||||
},
|
||||
title: '店铺授权提醒',
|
||||
content: formatNoticeContent(record.storeName),
|
||||
errorCode: 'RECIPIENT_NOT_FOUND',
|
||||
errorMessage: '店铺运营负责人、部门负责人、运营总监均无法接收(离职或未绑定钉钉)',
|
||||
})
|
||||
} catch {
|
||||
// 审计写入失败不影响推送结果,结果明细里已按失败记录原因。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const storeAccountAuthorizationNoticeService = new StoreAccountAuthorizationNoticeService()
|
||||
Loading…
x
Reference in New Issue
Block a user