Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-bom-estimate-agent-phase0
This commit is contained in:
commit
33a13a8abb
@ -50,6 +50,16 @@
|
||||
- 准确度回测:`npm run backtest:bom-cost-reference -- --sample-size=30`,只读最新成功镜像,不访问上游;样本限制 20 至 50 个,任一可比较 SKU 超过固定 10% 误差门槛即失败。
|
||||
- 迁移、真实同步、安装/启用定时器和生产回测都必须另行获得生产操作授权;部署代码本身不代表这些动作已经执行。
|
||||
|
||||
## 操作日志保留策略
|
||||
|
||||
- 清理脚本:`npm run db:cleanup-operation-logs` 默认只做 dry-run;只有显式传入 `--apply --confirm=cleanup-operation-logs` 才会删除数据。
|
||||
- 自动清理范围严格限定为 `OperationLog` 中 `SYSTEM + SUPPLY_CHAIN_PRODUCT_ARCHIVE + details.kind=UPDATED + SUCCESS` 且创建时间早于 30 天的字段变更。
|
||||
- 用户操作、商品档案导入、失败/重试日志及其他业务范围日志至少保留 365 天;本次策略不自动删除超过 365 天的审计日志。
|
||||
- 生产安装入口:`furniture-3d-system/scripts/install-operation-log-retention-timer.sh`。
|
||||
- 生产 systemd 名称:`furniture-operation-log-retention.service` / `furniture-operation-log-retention.timer`,默认每天中国标准时间 `04:30` 执行,附带最多 10 分钟随机延迟。
|
||||
- 安装脚本会在 `/opt/furniture-3d-system-prod/shared/operation-log-retention.env` 保存权限为 `600` 的数据库身份门禁和非敏感参数;身份未填写或保留天数不是 30 天时拒绝启用。
|
||||
- 首次启用前必须先在生产执行 dry-run,核对候选数量、数据库身份和 systemd 落点;生产安装、首次 `--apply` 和后续代码部署均需另行获得授权。
|
||||
|
||||
## 明确排除
|
||||
|
||||
- `192.168.58.20` 不是当前 `furniture-3d-system` 的生产部署目标。
|
||||
|
||||
@ -31,6 +31,8 @@
|
||||
"db:verify-master-data:production-readonly": "node scripts/verify-master-data-foundation.js --read-only-quality --summary-output .master-data-audits/foundation-production-readonly-current.summary.json --summary-stdout",
|
||||
"db:cleanup-master-data": "node scripts/apply-master-data-cleanup-plan.js",
|
||||
"db:cleanup-master-data:validate": "node scripts/apply-master-data-cleanup-plan.js --validate-plan",
|
||||
"db:cleanup-operation-logs": "tsx scripts/cleanup-operation-logs.ts",
|
||||
"db:cleanup-operation-logs:apply": "tsx scripts/cleanup-operation-logs.ts --apply --confirm=cleanup-operation-logs",
|
||||
"db:export-product-category-review": "node scripts/export-non-integrated-product-category-review.js",
|
||||
"db:build-product-category-cleanup-plan": "node scripts/build-non-integrated-product-category-cleanup-plan.js",
|
||||
"db:match-jst-product-category-cleanup-plan": "node scripts/match-jst-product-category-cleanup-plan.js",
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
-- Supports the bounded retention scan for system-generated product archive
|
||||
-- field-change logs without changing the audit data itself.
|
||||
CREATE INDEX "OperationLog_retention_scope_createdAt_idx"
|
||||
ON "OperationLog"("workspaceCategory", "source", "status", "createdAt");
|
||||
@ -3192,6 +3192,7 @@ model OperationLog {
|
||||
@@index([actorId, createdAt])
|
||||
@@index([productSpu, createdAt])
|
||||
@@index([productId, createdAt])
|
||||
@@index([workspaceCategory, source, status, createdAt], map: "OperationLog_retention_scope_createdAt_idx")
|
||||
}
|
||||
|
||||
// 家具系统发往统一通知中心的本地审计副本。
|
||||
|
||||
106
furniture-3d-system/scripts/cleanup-operation-logs.ts
Normal file
106
furniture-3d-system/scripts/cleanup-operation-logs.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { loadEnvConfig } from '@next/env'
|
||||
|
||||
loadEnvConfig(process.cwd())
|
||||
|
||||
const CONFIRM_TOKEN = 'cleanup-operation-logs'
|
||||
const DEFAULT_RETENTION_DAYS = 30
|
||||
const DEFAULT_BATCH_SIZE = 1000
|
||||
|
||||
function readArgument(name: string) {
|
||||
const prefix = `--${name}=`
|
||||
return (
|
||||
process.argv
|
||||
.slice(2)
|
||||
.find((argument) => argument.startsWith(prefix))
|
||||
?.slice(prefix.length) ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function databaseIdentity(databaseUrl: string) {
|
||||
const parsed = new URL(databaseUrl)
|
||||
return [
|
||||
parsed.protocol.replace(/:$/, ''),
|
||||
decodeURIComponent(parsed.username || ''),
|
||||
parsed.hostname,
|
||||
parsed.port || '',
|
||||
parsed.pathname.replace(/^\//, ''),
|
||||
parsed.searchParams.get('schema') ?? '',
|
||||
].join('|')
|
||||
}
|
||||
|
||||
function readPositiveInteger(name: string, fallback: number, max: number) {
|
||||
const value = Number(readArgument(name) || fallback)
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > max) {
|
||||
throw new Error(`--${name} must be an integer between 1 and ${max}.`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const databaseUrl = process.env.DATABASE_URL?.trim() ?? ''
|
||||
if (!databaseUrl) throw new Error('DATABASE_URL is required.')
|
||||
|
||||
const retentionDays = readPositiveInteger(
|
||||
'retention-days',
|
||||
Number(process.env.OPERATION_LOG_SYSTEM_FIELD_RETENTION_DAYS || DEFAULT_RETENTION_DAYS),
|
||||
365,
|
||||
)
|
||||
if (retentionDays !== DEFAULT_RETENTION_DAYS) {
|
||||
throw new Error(
|
||||
`The approved system field-change retention is ${DEFAULT_RETENTION_DAYS} days; do not override it in production.`,
|
||||
)
|
||||
}
|
||||
|
||||
const batchSize = readPositiveInteger(
|
||||
'batch-size',
|
||||
Number(process.env.OPERATION_LOG_CLEANUP_BATCH_SIZE || DEFAULT_BATCH_SIZE),
|
||||
5000,
|
||||
)
|
||||
const apply = process.argv.includes('--apply')
|
||||
if (apply && readArgument('confirm') !== CONFIRM_TOKEN) {
|
||||
throw new Error(`Pass --confirm=${CONFIRM_TOKEN} when using --apply.`)
|
||||
}
|
||||
|
||||
const actualDatabaseIdentity = databaseIdentity(databaseUrl)
|
||||
if (
|
||||
apply &&
|
||||
readArgument('expected-database-identity') !== actualDatabaseIdentity
|
||||
) {
|
||||
throw new Error(
|
||||
`Pass --expected-database-identity=${actualDatabaseIdentity} when using --apply.`,
|
||||
)
|
||||
}
|
||||
|
||||
const [{ prisma }, { operationLogRetentionService }] = await Promise.all([
|
||||
import('../src/lib/prisma'),
|
||||
import('../src/server/services/operation-log-retention.service'),
|
||||
])
|
||||
|
||||
try {
|
||||
const result = await operationLogRetentionService.cleanup({
|
||||
now: new Date(),
|
||||
batchSize,
|
||||
apply,
|
||||
})
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
...result,
|
||||
cutoff: result.cutoff.toISOString(),
|
||||
databaseIdentity: apply ? actualDatabaseIdentity : undefined,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
||||
export { databaseIdentity, readArgument, readPositiveInteger }
|
||||
@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}"
|
||||
SERVICE_NAME="${OPERATION_LOG_RETENTION_SERVICE_NAME:-furniture-operation-log-retention}"
|
||||
APP_ROOT="$BASE/current/furniture-3d-system"
|
||||
SHARED="$BASE/shared"
|
||||
APP_ENV="$SHARED/.env"
|
||||
RETENTION_ENV="$SHARED/operation-log-retention.env"
|
||||
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
TIMER_FILE="/etc/systemd/system/$SERVICE_NAME.timer"
|
||||
RETENTION_CALENDAR="${OPERATION_LOG_RETENTION_CALENDAR:-*-*-* 04:30:00 Asia/Shanghai}"
|
||||
RANDOMIZED_DELAY_SEC="${OPERATION_LOG_RETENTION_RANDOMIZED_DELAY_SEC:-10min}"
|
||||
CONFIRM_TOKEN="cleanup-operation-logs"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "This installer must run as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$APP_ROOT" ]; then
|
||||
echo "Missing app root: $APP_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$APP_ENV" ]; then
|
||||
echo "Missing app env: $APP_ENV" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$SHARED"
|
||||
if [ ! -f "$RETENTION_ENV" ]; then
|
||||
umask 077
|
||||
cat >"$RETENTION_ENV" <<'EOF'
|
||||
# Operation log retention controls. Do not commit secrets to Git.
|
||||
# Must match the production DATABASE_URL identity before enabling the timer.
|
||||
OPERATION_LOG_EXPECTED_DATABASE_IDENTITY=
|
||||
OPERATION_LOG_SYSTEM_FIELD_RETENTION_DAYS=30
|
||||
OPERATION_LOG_CLEANUP_BATCH_SIZE=1000
|
||||
EOF
|
||||
echo "Created env template: $RETENTION_ENV"
|
||||
fi
|
||||
|
||||
chmod 600 "$RETENTION_ENV"
|
||||
|
||||
if ! grep -Eq '^OPERATION_LOG_EXPECTED_DATABASE_IDENTITY=.+$' "$RETENTION_ENV"; then
|
||||
echo "Missing OPERATION_LOG_EXPECTED_DATABASE_IDENTITY in $RETENTION_ENV; timer was not installed." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! grep -Eq '^DATABASE_URL=.+$' "$APP_ENV"; then
|
||||
echo "Missing DATABASE_URL in $APP_ENV; timer was not installed." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! grep -Eq '^OPERATION_LOG_SYSTEM_FIELD_RETENTION_DAYS=30$' "$RETENTION_ENV"; then
|
||||
echo "OPERATION_LOG_SYSTEM_FIELD_RETENTION_DAYS must be 30; timer was not installed." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||
systemd-analyze calendar "$RETENTION_CALENDAR" >/dev/null
|
||||
fi
|
||||
|
||||
cat >"$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Clean expired system operation log field changes
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=$APP_ROOT
|
||||
Environment=NODE_ENV=production
|
||||
EnvironmentFile=$APP_ENV
|
||||
EnvironmentFile=$RETENTION_ENV
|
||||
ExecStart=/bin/bash -lc 'set -euo pipefail; cd "$APP_ROOT"; ./node_modules/.bin/tsx scripts/cleanup-operation-logs.ts --apply --confirm=$CONFIRM_TOKEN --expected-database-identity="\${OPERATION_LOG_EXPECTED_DATABASE_IDENTITY}"'
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
TimeoutStartSec=30min
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
EOF
|
||||
|
||||
cat >"$TIMER_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Run operation log retention cleanup daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=$RETENTION_CALENDAR
|
||||
Persistent=true
|
||||
RandomizedDelaySec=$RANDOMIZED_DELAY_SEC
|
||||
Unit=$SERVICE_NAME.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now "$SERVICE_NAME.timer"
|
||||
systemctl list-timers --all "$SERVICE_NAME.timer" --no-pager
|
||||
@ -0,0 +1,50 @@
|
||||
const mockHandleRequest = jest.fn()
|
||||
const mockJsonSuccess = jest.fn()
|
||||
const mockUpdateSelection = jest.fn()
|
||||
const mockListSelectionOperators = jest.fn()
|
||||
|
||||
jest.mock('@/server/core/response', () => ({
|
||||
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
|
||||
jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/supplier-recommended-products.service', () => ({
|
||||
supplierRecommendedProductsService: {
|
||||
updateSelection: (...args: unknown[]) => mockUpdateSelection(...args),
|
||||
listSelectionOperators: (...args: unknown[]) => mockListSelectionOperators(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const { GET, POST } = require('./route')
|
||||
|
||||
describe('POST /api/supply-chain/supplier-recommended-products/selection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockHandleRequest.mockImplementation(async (_request: unknown, handler: (ctx: unknown) => unknown) => handler({
|
||||
requestId: 'req-selection',
|
||||
user: { id: 'operator-1', name: '运营', role: 'USER', businessRoles: ['OPERATIONS'] },
|
||||
}))
|
||||
mockJsonSuccess.mockImplementation((data, status, requestId) => ({ data, status, requestId }))
|
||||
mockUpdateSelection.mockResolvedValue({ action: 'select', requestedCount: 2, updatedCount: 2 })
|
||||
mockListSelectionOperators.mockResolvedValue([{ id: 'operator-2', name: '运营乙', departmentName: '运营中心' }])
|
||||
})
|
||||
|
||||
it('passes the batch selection payload and authenticated user to the service', async () => {
|
||||
const response = await POST({
|
||||
json: async () => ({ ids: ['recommended-1', 'recommended-2'], action: 'select' }),
|
||||
} as never)
|
||||
|
||||
expect(mockUpdateSelection).toHaveBeenCalledWith(
|
||||
{ ids: ['recommended-1', 'recommended-2'], action: 'select' },
|
||||
expect.objectContaining({ id: 'operator-1', businessRoles: ['OPERATIONS'] }),
|
||||
)
|
||||
expect(response).toEqual(expect.objectContaining({ status: 200 }))
|
||||
})
|
||||
|
||||
it('returns searchable active operator candidates', async () => {
|
||||
const response = await GET({ nextUrl: new URL('http://localhost/api/supply-chain/supplier-recommended-products/selection?search=%E8%BF%90%E8%90%A5') })
|
||||
|
||||
expect(mockListSelectionOperators).toHaveBeenCalledWith('运营', expect.objectContaining({ id: 'operator-1' }))
|
||||
expect(response).toEqual(expect.objectContaining({ status: 200 }))
|
||||
})
|
||||
})
|
||||
@ -221,7 +221,7 @@ export function SupplierRecommendedProductsClient({ initialData, canEdit, canDow
|
||||
{(['all', 'unselected', 'selected'] as const).map((selection) => <button key={selection} type="button" aria-pressed={filters.selection === selection} onClick={() => changeSelectionFilter(selection)} className={filters.selection === selection ? 'recommended-selection-switch-active' : ''}>{selection === 'all' ? '全部' : selection === 'unselected' ? '未选' : '已选'}</button>)}
|
||||
</div>
|
||||
<SupplierRecommendedProductColumnSettings columns={columns.orderedColumns} visibleKeys={columns.visibleKeySet} hiddenCount={columns.hiddenCount} onToggle={columns.toggle} onMove={columns.move} onShowAll={columns.showAll} onHideAll={columns.hideAll} onReset={columns.reset} />
|
||||
{canDownload && <button type="button" className="recommended-control-button" onClick={() => { window.location.href = '/api/supply-chain/supplier-recommended-products/template' }}><FileDown size={15} />下载模板</button>}
|
||||
{canEdit && <button type="button" className="recommended-control-button" onClick={() => { window.location.href = '/api/supply-chain/supplier-recommended-products/template' }}><FileDown size={15} />下载模板</button>}
|
||||
{canEdit && <>
|
||||
<button type="button" onClick={batchRemove} disabled={selectedIds.size === 0 || batchDeleting || deletingId !== null} className="recommended-danger-button recommended-control-button"><Trash2 size={15} />{batchDeleting ? '删除中' : `批量删除${selectedIds.size ? `(${selectedIds.size})` : ''}`}</button>
|
||||
<button type="button" disabled={importTask.isImporting || batchDeleting} onClick={() => fileRef.current?.click()} className="recommended-control-button"><Upload size={15} />{importTask.isImporting ? '导入中' : '导入'}</button>
|
||||
|
||||
@ -39,6 +39,6 @@ export function SupplierRecommendedProductEditor({ item, onClose, onSaved }: { i
|
||||
event.preventDefault()
|
||||
void uploadImageFiles(files)
|
||||
}
|
||||
return <div className="fixed inset-0 z-[70] flex justify-end bg-black/30" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) onClose() }}><section role="dialog" aria-modal="true" aria-label={item ? '编辑供应商新品推荐' : '新增供应商新品推荐'} onPaste={handleDrawerPaste} className="flex h-full w-full max-w-[780px] flex-col bg-[#f7f8f8] shadow-2xl"><header className="flex h-16 shrink-0 items-center justify-between border-b border-black/10 bg-white px-6"><div><h2 className="text-base font-bold text-zinc-900">{item ? '编辑供应商新品推荐' : '新增供应商新品推荐'}</h2><p className="mt-0.5 text-xs text-zinc-500">供应商推荐信息人工维护,空白字段可保存</p></div><button type="button" title="关闭" disabled={saving} onClick={onClose} className="recommended-icon-button"><X size={18} /></button></header><div className="min-h-0 flex-1 overflow-y-auto p-6"><div className="grid gap-4 rounded-[7px] border border-black/10 bg-white p-5 sm:grid-cols-2"><label className="recommended-field"><span>日期</span><input type="date" value={draft.recommendationDate ?? ''} onChange={(event) => set('recommendationDate', event.target.value || null)} /></label><Field label="类目" value={draft.category} onChange={(value) => set('category', value)} /><Field label="供应商" value={draft.supplier} onChange={(value) => set('supplier', value)} /><Field label="采购" value={draft.purchaser} onChange={(value) => set('purchaser', value)} /><Field label="产品代码(供应商自己的)" value={draft.productCode} onChange={(value) => set('productCode', value)} /><Field label="申请寄样" value={draft.sampleRequest} onChange={(value) => set('sampleRequest', value)} /><Field label="尺寸" value={draft.dimensions} onChange={(value) => set('dimensions', value)} /><Field label="价格(含税价+专票)" value={draft.price} onChange={(value) => set('price', value)} /><Field label="材质" value={draft.material} onChange={(value) => set('material', value)} /><Field label="板材等级" value={draft.boardGrade} onChange={(value) => set('boardGrade', value)} /><Field label="重量" value={draft.weight} onChange={(value) => set('weight', value)} /><label className="recommended-field"><span>下单量</span><input type="number" min={0} step={1} value={draft.orderQuantity} onChange={(event) => set('orderQuantity', Math.max(0, Math.trunc(Number(event.target.value) || 0)))} /></label><Field label="生产周期" value={draft.productionCycle} onChange={(value) => set('productionCycle', value)} /><Field label="发货模式" value={draft.shippingMode} onChange={(value) => set('shippingMode', value)} /><Field label="代发运费(代发情况下)" value={draft.dropShippingFee} onChange={(value) => set('dropShippingFee', value)} /><div className="recommended-field sm:col-span-2"><label className="recommended-field"><span>图片 URL</span><input value={draft.imageUrl} onChange={(event) => set('imageUrl', event.target.value)} placeholder="https://... 或在抽屉内 Ctrl+V 粘贴截图" /></label><div className="mt-2 flex items-center gap-3"><label className={`recommended-control-button shrink-0 cursor-pointer ${uploadingImageName ? 'pointer-events-none opacity-60' : ''}`}><input type="file" accept="image/*" className="hidden" disabled={Boolean(uploadingImageName)} onChange={(event) => { void uploadImageFiles(Array.from(event.target.files ?? [])); event.currentTarget.value = '' }} />上传图片</label>{uploadingImageName && <span className="text-xs text-zinc-500">上传中:{uploadingImageName}</span>}{imageError && <span className="text-xs text-red-600" role="alert">{imageError}</span>}</div>{!uploadingImageName && draft.imageUrl && <Image unoptimized src={draft.imageUrl} alt="图片预览" width={96} height={96} className="mt-2 h-24 w-24 rounded-[5px] border border-zinc-200 object-cover" />}</div></div></div><footer className="flex shrink-0 justify-end gap-3 border-t border-black/10 bg-white px-6 py-4"><button type="button" disabled={saving} onClick={onClose} className="recommended-secondary-button">取消</button><button type="button" disabled={saving} onClick={() => void save()} className="recommended-primary-button"><Save size={15} />{saving ? '保存中...' : '保存'}</button></footer></section></div>
|
||||
return <div className="fixed inset-0 z-[70] flex justify-end bg-black/30" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) onClose() }}><section role="dialog" aria-modal="true" aria-label={item ? '编辑供应商新品推荐' : '新增供应商新品推荐'} onPaste={handleDrawerPaste} className="flex h-full w-full max-w-[780px] flex-col bg-[#f7f8f8] shadow-2xl"><header className="flex h-16 shrink-0 items-center justify-between border-b border-black/10 bg-white px-6"><div><h2 className="text-base font-bold text-zinc-900">{item ? '编辑供应商新品推荐' : '新增供应商新品推荐'}</h2><p className="mt-0.5 text-xs text-zinc-500">供应商推荐信息人工维护,空白字段可保存</p></div><button type="button" title="关闭" disabled={saving} onClick={onClose} className="recommended-icon-button"><X size={18} /></button></header><div className="min-h-0 flex-1 overflow-y-auto p-6"><div className="grid gap-4 rounded-[7px] border border-black/10 bg-white p-5 sm:grid-cols-2"><label className="recommended-field"><span>日期</span><input type="date" value={draft.recommendationDate ?? ''} onChange={(event) => set('recommendationDate', event.target.value || null)} /></label><Field label="类目" value={draft.category} onChange={(value) => set('category', value)} /><Field label="供应商" value={draft.supplier} onChange={(value) => set('supplier', value)} /><Field label="采购" value={draft.purchaser} onChange={(value) => set('purchaser', value)} /><Field label="产品代码(供应商自己的)" value={draft.productCode} onChange={(value) => set('productCode', value)} /><Field label="尺寸" value={draft.dimensions} onChange={(value) => set('dimensions', value)} /><Field label="价格(含税价+专票)" value={draft.price} onChange={(value) => set('price', value)} /><Field label="材质" value={draft.material} onChange={(value) => set('material', value)} /><Field label="板材等级" value={draft.boardGrade} onChange={(value) => set('boardGrade', value)} /><Field label="重量" value={draft.weight} onChange={(value) => set('weight', value)} /><label className="recommended-field"><span>下单量</span><input type="number" min={0} step={1} value={draft.orderQuantity} onChange={(event) => set('orderQuantity', Math.max(0, Math.trunc(Number(event.target.value) || 0)))} /></label><Field label="生产周期" value={draft.productionCycle} onChange={(value) => set('productionCycle', value)} /><Field label="发货模式" value={draft.shippingMode} onChange={(value) => set('shippingMode', value)} /><Field label="代发运费(代发情况下)" value={draft.dropShippingFee} onChange={(value) => set('dropShippingFee', value)} /><Field label="申请寄样" value={draft.sampleRequest} onChange={(value) => set('sampleRequest', value)} /><div className="recommended-field sm:col-span-2"><label className="recommended-field"><span>图片 URL</span><input value={draft.imageUrl} onChange={(event) => set('imageUrl', event.target.value)} placeholder="https://... 或在抽屉内 Ctrl+V 粘贴截图" /></label><div className="mt-2 flex items-center gap-3"><label className={`recommended-control-button shrink-0 cursor-pointer ${uploadingImageName ? 'pointer-events-none opacity-60' : ''}`}><input type="file" accept="image/*" className="hidden" disabled={Boolean(uploadingImageName)} onChange={(event) => { void uploadImageFiles(Array.from(event.target.files ?? [])); event.currentTarget.value = '' }} />上传图片</label>{uploadingImageName && <span className="text-xs text-zinc-500">上传中:{uploadingImageName}</span>}{imageError && <span className="text-xs text-red-600" role="alert">{imageError}</span>}</div>{!uploadingImageName && draft.imageUrl && <Image unoptimized src={draft.imageUrl} alt="图片预览" width={96} height={96} className="mt-2 h-24 w-24 rounded-[5px] border border-zinc-200 object-cover" />}</div></div></div><footer className="flex shrink-0 justify-end gap-3 border-t border-black/10 bg-white px-6 py-4"><button type="button" disabled={saving} onClick={onClose} className="recommended-secondary-button">取消</button><button type="button" disabled={saving} onClick={() => void save()} className="recommended-primary-button"><Save size={15} />{saving ? '保存中...' : '保存'}</button></footer></section></div>
|
||||
}
|
||||
function Field({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { return <label className="recommended-field"><span>{label}</span><input value={value} onChange={(event) => onChange(event.target.value)} /></label> }
|
||||
|
||||
@ -10,7 +10,6 @@ export const SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS: readonly SupplierRecommendedP
|
||||
{ key: 'purchaser', label: '采购', minWidth: 110 },
|
||||
{ key: 'productCode', label: '产品代码(供应商自己的)', minWidth: 190 },
|
||||
{ key: 'imageUrl', label: '图片', minWidth: 86 },
|
||||
{ key: 'sampleRequest', label: '申请寄样', minWidth: 120 },
|
||||
{ key: 'dimensions', label: '尺寸', minWidth: 150 },
|
||||
{ key: 'price', label: '价格(含税价+专票)', minWidth: 170 },
|
||||
{ key: 'material', label: '材质', minWidth: 140 },
|
||||
@ -20,6 +19,7 @@ export const SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS: readonly SupplierRecommendedP
|
||||
{ key: 'productionCycle', label: '生产周期', minWidth: 120 },
|
||||
{ key: 'shippingMode', label: '发货模式', minWidth: 120 },
|
||||
{ key: 'dropShippingFee', label: '代发运费(代发情况下)', minWidth: 190 },
|
||||
{ key: 'sampleRequest', label: '申请寄样', minWidth: 120 },
|
||||
{ key: 'actions', label: '操作', minWidth: 220, locked: true },
|
||||
]
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AccountManagementPanel } from './AccountManagementPanel'
|
||||
import { AccountManagementPanel, formatLastLoginLabel } from './AccountManagementPanel'
|
||||
import { buildBuiltinPermissionRoles } from '@/lib/permission-roles'
|
||||
import type { PermissionDepartment, PermissionManagedUser } from './usePermissionManagedUsers'
|
||||
|
||||
@ -88,4 +88,42 @@ describe('AccountManagementPanel', () => {
|
||||
expect(screen.queryByText('采购乙')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('共 1 个账号')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a last login column that distinguishes never, recent, and stale logins', () => {
|
||||
render(
|
||||
<AccountManagementPanel
|
||||
users={[
|
||||
{ id: 'login-1', name: '从未甲', email: 'never@example.com', role: 'USER', status: 'ACTIVE', lastLoginAt: null },
|
||||
{ id: 'login-2', name: '近期乙', email: 'recent@example.com', role: 'USER', status: 'ACTIVE', lastLoginAt: new Date(Date.now() - 3 * 86400000).toISOString() },
|
||||
{ id: 'login-3', name: '久未丙', email: 'stale@example.com', role: 'USER', status: 'ACTIVE', lastLoginAt: '2025-01-06T10:00:00+08:00' },
|
||||
]}
|
||||
departments={departments}
|
||||
loading={false}
|
||||
error={null}
|
||||
onSelect={jest.fn()}
|
||||
onCreate={jest.fn()}
|
||||
onEditAccount={jest.fn()}
|
||||
onEditPermissions={jest.fn()}
|
||||
onSelectDepartment={jest.fn()}
|
||||
onActivate={jest.fn()}
|
||||
permissionRoles={buildBuiltinPermissionRoles()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('最后登录')).toBeInTheDocument()
|
||||
expect(screen.getByText('从未登录')).toBeInTheDocument()
|
||||
expect(screen.getByText('3 天前')).toBeInTheDocument()
|
||||
expect(screen.getByText('2025-01-06')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('formats last login labels with relative dates and stale tone', () => {
|
||||
const now = new Date('2026-09-02T15:00:00+08:00')
|
||||
|
||||
expect(formatLastLoginLabel(null, now)).toEqual({ text: '从未登录', tone: 'muted' })
|
||||
expect(formatLastLoginLabel('2026-09-02T09:30:00+08:00', now)).toEqual({ text: '今天 09:30', tone: 'normal' })
|
||||
expect(formatLastLoginLabel('2026-09-01T10:00:00+08:00', now)).toEqual({ text: '昨天', tone: 'normal' })
|
||||
expect(formatLastLoginLabel('2026-08-31T10:00:00+08:00', now)).toEqual({ text: '2 天前', tone: 'normal' })
|
||||
expect(formatLastLoginLabel('2026-08-20T10:00:00+08:00', now)).toEqual({ text: '2026-08-20', tone: 'normal' })
|
||||
expect(formatLastLoginLabel('2026-05-01T10:00:00+08:00', now)).toEqual({ text: '2026-05-01', tone: 'stale' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -19,6 +19,30 @@ const STATUS_STYLES: Record<string, string> = {
|
||||
SUSPENDED: 'bg-[#fff0ef] text-[#b96864]',
|
||||
}
|
||||
|
||||
const LAST_LOGIN_TONE_STYLES = {
|
||||
normal: 'text-[#657575]',
|
||||
stale: 'text-[#c07f2f]',
|
||||
muted: 'text-[#a9b4b3]',
|
||||
} as const
|
||||
|
||||
export function formatLastLoginLabel(value: string | null | undefined, now: Date = new Date()): { text: string; tone: keyof typeof LAST_LOGIN_TONE_STYLES } {
|
||||
if (!value) return { text: '从未登录', tone: 'muted' }
|
||||
const last = new Date(value)
|
||||
if (Number.isNaN(last.getTime())) return { text: '从未登录', tone: 'muted' }
|
||||
|
||||
const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
|
||||
const days = Math.floor((startOfDay(now) - startOfDay(last)) / 86400000)
|
||||
if (days <= 0) {
|
||||
const time = `${String(last.getHours()).padStart(2, '0')}:${String(last.getMinutes()).padStart(2, '0')}`
|
||||
return { text: `今天 ${time}`, tone: 'normal' }
|
||||
}
|
||||
if (days === 1) return { text: '昨天', tone: 'normal' }
|
||||
if (days <= 6) return { text: `${days} 天前`, tone: 'normal' }
|
||||
|
||||
const dateText = `${last.getFullYear()}-${String(last.getMonth() + 1).padStart(2, '0')}-${String(last.getDate()).padStart(2, '0')}`
|
||||
return days <= 30 ? { text: dateText, tone: 'normal' } : { text: dateText, tone: 'stale' }
|
||||
}
|
||||
|
||||
export function AccountManagementPanel({
|
||||
users,
|
||||
loading,
|
||||
@ -196,15 +220,17 @@ export function AccountManagementPanel({
|
||||
<th className="border-b border-[#e4e8e7] px-3 py-3 text-left font-bold">部门</th>
|
||||
<th className="border-b border-[#e4e8e7] px-3 py-3 text-left font-bold">主岗位</th>
|
||||
<th className="border-b border-[#e4e8e7] px-3 py-3 text-left font-bold">账号状态</th>
|
||||
<th className="border-b border-[#e4e8e7] px-3 py-3 text-left font-bold">最后登录</th>
|
||||
<th className="border-b border-[#e4e8e7] px-5 py-3 text-right font-bold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? <tr><td colSpan={5} className="px-5 py-12 text-center text-[12px] text-[#899594]">正在读取账号…</td></tr> : null}
|
||||
{!loading && filteredUsers.length === 0 ? <tr><td colSpan={5} className="px-5 py-12 text-center text-[12px] text-[#899594]">没有匹配的账号</td></tr> : null}
|
||||
{loading ? <tr><td colSpan={6} className="px-5 py-12 text-center text-[12px] text-[#899594]">正在读取账号…</td></tr> : null}
|
||||
{!loading && filteredUsers.length === 0 ? <tr><td colSpan={6} className="px-5 py-12 text-center text-[12px] text-[#899594]">没有匹配的账号</td></tr> : null}
|
||||
{!loading ? filteredUsers.map((user) => {
|
||||
const active = selectedUserId === user.id
|
||||
const primaryRoleId = resolveUserPermissionRoleId(user)
|
||||
const lastLoginLabel = formatLastLoginLabel(user.lastLoginAt)
|
||||
return (
|
||||
<tr key={user.id} onClick={() => onSelect(user)} className={`cursor-pointer border-b border-[#edf0ef] transition ${active ? 'bg-[#f0f5f3]' : 'bg-white hover:bg-[#fafcfc]'}`}>
|
||||
<td className="px-5 py-3">
|
||||
@ -219,6 +245,7 @@ export function AccountManagementPanel({
|
||||
<td className="max-w-[220px] truncate px-3 py-3 text-[#657575]">{user.department?.fullPath || user.department?.name || '未归属部门'}</td>
|
||||
<td className="px-3 py-3"><span className="rounded-full bg-[#f1f5f4] px-2.5 py-1 text-[11px] font-bold text-[#637571]">{primaryRoleId ? roleLabels.get(primaryRoleId) ?? '已停用岗位' : '未设置'}</span></td>
|
||||
<td className="px-3 py-3"><span className={`rounded-full px-2.5 py-1 text-[11px] font-bold ${STATUS_STYLES[user.status] ?? STATUS_STYLES.INACTIVE}`}>{STATUS_LABELS[user.status] ?? user.status}</span></td>
|
||||
<td className={`whitespace-nowrap px-3 py-3 text-[11px] ${LAST_LOGIN_TONE_STYLES[lastLoginLabel.tone]}`}>{lastLoginLabel.text}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
{user.status === 'PENDING' ? <button type="button" onClick={(event) => { event.stopPropagation(); onActivate(user) }} className="rounded-[6px] border border-[#d8c999] bg-[#fffaf0] px-2.5 py-1.5 text-[11px] font-bold text-[#947437] hover:bg-[#fff4d8]">激活</button> : null}
|
||||
|
||||
@ -17,6 +17,7 @@ export type PermissionManagedUser = {
|
||||
supplierName?: string | null
|
||||
department?: { id?: string; name?: string; fullPath?: string } | null
|
||||
departmentPaths?: string[]
|
||||
lastLoginAt?: string | null
|
||||
}
|
||||
|
||||
export type PermissionDepartment = {
|
||||
|
||||
@ -7,7 +7,6 @@ export const SUPPLIER_RECOMMENDED_PRODUCT_FIELDS = [
|
||||
{ key: 'purchaser', label: '采购' },
|
||||
{ key: 'productCode', label: '产品代码(供应商自己的)' },
|
||||
{ key: 'imageUrl', label: '图片' },
|
||||
{ key: 'sampleRequest', label: '申请寄样' },
|
||||
{ key: 'dimensions', label: '尺寸' },
|
||||
{ key: 'price', label: '价格(含税价+专票)' },
|
||||
{ key: 'material', label: '材质' },
|
||||
@ -17,8 +16,14 @@ export const SUPPLIER_RECOMMENDED_PRODUCT_FIELDS = [
|
||||
{ key: 'productionCycle', label: '生产周期' },
|
||||
{ key: 'shippingMode', label: '发货模式' },
|
||||
{ key: 'dropShippingFee', label: '代发运费(代发情况下)' },
|
||||
{ key: 'sampleRequest', label: '申请寄样' },
|
||||
] as const
|
||||
|
||||
// 导入必填:除申请寄样(可空)外全部必填;日期由系统按导入日期填写,不在文件里要求。
|
||||
export const SUPPLIER_RECOMMENDED_PRODUCT_REQUIRED_KEYS: readonly (typeof SUPPLIER_RECOMMENDED_PRODUCT_FIELDS)[number]['key'][] = SUPPLIER_RECOMMENDED_PRODUCT_FIELDS
|
||||
.filter((field) => field.key !== 'sampleRequest' && field.key !== 'recommendationDate')
|
||||
.map((field) => field.key)
|
||||
|
||||
export type SupplierRecommendedProductFieldKey = typeof SUPPLIER_RECOMMENDED_PRODUCT_FIELDS[number]['key']
|
||||
|
||||
export const SUPPLIER_RECOMMENDED_PRODUCT_SELECTION_STATUSES = ['UNSELECTED', 'SELECTED'] as const
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
import {
|
||||
OperationLogStatus,
|
||||
Prisma,
|
||||
} from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const OPERATION_LOG_RETENTION_SCOPE = 'SUPPLY_CHAIN_PRODUCT_ARCHIVE'
|
||||
|
||||
export function buildOperationLogRetentionWhere(
|
||||
cutoff: Date,
|
||||
): Prisma.OperationLogWhereInput {
|
||||
return {
|
||||
createdAt: { lt: cutoff },
|
||||
source: 'SYSTEM',
|
||||
workspaceCategory: OPERATION_LOG_RETENTION_SCOPE,
|
||||
status: OperationLogStatus.SUCCESS,
|
||||
details: {
|
||||
path: ['kind'],
|
||||
equals: 'UPDATED',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface OperationLogRetentionRepository {
|
||||
countEligible(cutoff: Date): Promise<number>
|
||||
findEligibleIds(input: {
|
||||
cutoff: Date
|
||||
batchSize: number
|
||||
}): Promise<string[]>
|
||||
deleteByIds(ids: string[]): Promise<number>
|
||||
}
|
||||
|
||||
class OperationLogRetentionRepositoryClass
|
||||
implements OperationLogRetentionRepository
|
||||
{
|
||||
async countEligible(cutoff: Date) {
|
||||
return prisma.operationLog.count({
|
||||
where: buildOperationLogRetentionWhere(cutoff),
|
||||
})
|
||||
}
|
||||
|
||||
async findEligibleIds(input: { cutoff: Date; batchSize: number }) {
|
||||
const rows = await prisma.operationLog.findMany({
|
||||
where: buildOperationLogRetentionWhere(input.cutoff),
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
take: input.batchSize,
|
||||
select: { id: true },
|
||||
})
|
||||
return rows.map((row) => row.id)
|
||||
}
|
||||
|
||||
async deleteByIds(ids: string[]) {
|
||||
if (ids.length === 0) return 0
|
||||
const result = await prisma.operationLog.deleteMany({
|
||||
where: { id: { in: ids } },
|
||||
})
|
||||
return result.count
|
||||
}
|
||||
}
|
||||
|
||||
export const operationLogRetentionRepository =
|
||||
new OperationLogRetentionRepositoryClass()
|
||||
|
||||
export { OperationLogRetentionRepositoryClass }
|
||||
@ -93,20 +93,21 @@ describe('SupplierRecommendedProductsService', () => {
|
||||
|
||||
await expect(service.importRows([{
|
||||
recommendationDate: '1999-01-01',
|
||||
category: '床头柜',
|
||||
supplier: '供应商A',
|
||||
purchaser: '',
|
||||
productCode: '',
|
||||
purchaser: '采购员',
|
||||
productCode: 'A-001',
|
||||
imageUrl: '/api/storage/object?key=import-image',
|
||||
sampleRequest: '',
|
||||
dimensions: '',
|
||||
price: '',
|
||||
material: '',
|
||||
boardGrade: '',
|
||||
weight: '',
|
||||
dimensions: '40/40/60',
|
||||
price: '99',
|
||||
material: '橡胶木',
|
||||
boardGrade: 'enf',
|
||||
weight: '10',
|
||||
orderQuantity: 10,
|
||||
productionCycle: '',
|
||||
shippingMode: '',
|
||||
dropShippingFee: '',
|
||||
productionCycle: '15',
|
||||
shippingMode: '代发',
|
||||
dropShippingFee: '15',
|
||||
}], USER, progress)).resolves.toEqual({ created: 1, updated: 0 })
|
||||
|
||||
expect(importCreate).toHaveBeenCalledWith(
|
||||
@ -117,6 +118,58 @@ describe('SupplierRecommendedProductsService', () => {
|
||||
expect(progress).toHaveBeenCalledWith({ processed: 1, total: 1, created: 1, updated: 0 })
|
||||
})
|
||||
|
||||
it('rejects imported rows missing required fields and reports the row number and labels', async () => {
|
||||
const importCreate = jest.fn()
|
||||
const repository = { importCreate } as unknown as SupplierRecommendedProductsRepository
|
||||
const service = new SupplierRecommendedProductsService(repository)
|
||||
|
||||
await expect(service.importRows([{
|
||||
category: '',
|
||||
supplier: '',
|
||||
purchaser: '',
|
||||
productCode: '',
|
||||
imageUrl: '',
|
||||
dimensions: '',
|
||||
price: '',
|
||||
material: '',
|
||||
boardGrade: '',
|
||||
weight: '',
|
||||
orderQuantity: '',
|
||||
productionCycle: '',
|
||||
shippingMode: '代发',
|
||||
dropShippingFee: '',
|
||||
}], USER)).rejects.toThrow(/第2行:缺少必填字段:.+类目/)
|
||||
|
||||
expect(importCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires drop-shipping fee only when shipping mode is 代发', async () => {
|
||||
const importCreate = jest.fn().mockResolvedValue({ id: 'recommended-import-1' })
|
||||
const repository = { importCreate } as unknown as SupplierRecommendedProductsRepository
|
||||
const service = new SupplierRecommendedProductsService(repository)
|
||||
const base = {
|
||||
category: '床头柜',
|
||||
supplier: '供应商A',
|
||||
purchaser: '采购员',
|
||||
productCode: 'A-001',
|
||||
imageUrl: '/api/storage/object?key=import-image',
|
||||
dimensions: '40/40/60',
|
||||
price: '99',
|
||||
material: '橡胶木',
|
||||
boardGrade: 'enf',
|
||||
weight: '10',
|
||||
orderQuantity: 10,
|
||||
productionCycle: '15',
|
||||
}
|
||||
|
||||
await expect(service.importRows([{ ...base, shippingMode: '自提', dropShippingFee: '', sampleRequest: '' }], USER))
|
||||
.resolves.toEqual({ created: 1, updated: 0 })
|
||||
|
||||
await expect(service.importRows([{ ...base, shippingMode: '代发', dropShippingFee: '', sampleRequest: '' }], USER))
|
||||
.rejects.toThrow('缺少必填字段:代发运费(代发情况下)')
|
||||
expect(importCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('requires and persists the selected operator for batch selection', async () => {
|
||||
const updateSelection = jest.fn().mockResolvedValue({ foundCount: 2, updatedCount: 2 })
|
||||
const repository = { updateSelection } as unknown as SupplierRecommendedProductsRepository
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
import {
|
||||
AUDIT_LOG_MIN_RETENTION_DAYS,
|
||||
DEFAULT_OPERATION_LOG_CLEANUP_BATCH_SIZE,
|
||||
OperationLogRetentionService,
|
||||
SYSTEM_FIELD_CHANGE_RETENTION_DAYS,
|
||||
getOperationLogRetentionCutoff,
|
||||
getOperationLogRetentionPolicy,
|
||||
} from './operation-log-retention.service'
|
||||
|
||||
describe('operation log retention service', () => {
|
||||
it('uses the approved 30-day cutoff and exposes the protected audit policy', () => {
|
||||
const now = new Date('2026-09-03T00:00:00.000Z')
|
||||
|
||||
expect(getOperationLogRetentionCutoff(now)).toEqual(
|
||||
new Date('2026-08-04T00:00:00.000Z'),
|
||||
)
|
||||
expect(getOperationLogRetentionPolicy()).toEqual(
|
||||
expect.objectContaining({
|
||||
systemFieldChangeRetentionDays: SYSTEM_FIELD_CHANGE_RETENTION_DAYS,
|
||||
auditLogMinimumRetentionDays: AUDIT_LOG_MIN_RETENTION_DAYS,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps dry-run read-only', async () => {
|
||||
const repository = {
|
||||
countEligible: jest.fn().mockResolvedValue(12),
|
||||
findEligibleIds: jest.fn(),
|
||||
deleteByIds: jest.fn(),
|
||||
}
|
||||
const service = new OperationLogRetentionService(repository)
|
||||
|
||||
const result = await service.cleanup({
|
||||
now: new Date('2026-09-03T00:00:00.000Z'),
|
||||
})
|
||||
|
||||
expect(result.mode).toBe('dry-run')
|
||||
expect(result.candidates).toBe(12)
|
||||
expect(result.deleted).toBe(0)
|
||||
expect(result.batchSize).toBe(DEFAULT_OPERATION_LOG_CLEANUP_BATCH_SIZE)
|
||||
expect(repository.findEligibleIds).not.toHaveBeenCalled()
|
||||
expect(repository.deleteByIds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes only in bounded batches when apply is explicit', async () => {
|
||||
const repository = {
|
||||
countEligible: jest.fn().mockResolvedValue(3),
|
||||
findEligibleIds: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(['log-1', 'log-2'])
|
||||
.mockResolvedValueOnce(['log-3'])
|
||||
.mockResolvedValueOnce([]),
|
||||
deleteByIds: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(2)
|
||||
.mockResolvedValueOnce(1),
|
||||
}
|
||||
const service = new OperationLogRetentionService(repository)
|
||||
|
||||
const result = await service.cleanup({
|
||||
now: new Date('2026-09-03T00:00:00.000Z'),
|
||||
batchSize: 2,
|
||||
apply: true,
|
||||
})
|
||||
|
||||
expect(result.mode).toBe('apply')
|
||||
expect(result.candidates).toBe(3)
|
||||
expect(result.deleted).toBe(3)
|
||||
expect(repository.findEligibleIds).toHaveBeenNthCalledWith(1, {
|
||||
cutoff: new Date('2026-08-04T00:00:00.000Z'),
|
||||
batchSize: 2,
|
||||
})
|
||||
expect(repository.deleteByIds).toHaveBeenNthCalledWith(1, ['log-1', 'log-2'])
|
||||
expect(repository.deleteByIds).toHaveBeenNthCalledWith(2, ['log-3'])
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,107 @@
|
||||
import {
|
||||
operationLogRetentionRepository,
|
||||
type OperationLogRetentionRepository,
|
||||
} from '@/server/repositories/operation-log-retention.repository'
|
||||
|
||||
export const SYSTEM_FIELD_CHANGE_RETENTION_DAYS = 30
|
||||
export const AUDIT_LOG_MIN_RETENTION_DAYS = 365
|
||||
export const DEFAULT_OPERATION_LOG_CLEANUP_BATCH_SIZE = 1000
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export type OperationLogRetentionPolicy = {
|
||||
systemFieldChangeRetentionDays: number
|
||||
auditLogMinimumRetentionDays: number
|
||||
protectedCategories: readonly string[]
|
||||
}
|
||||
|
||||
export type OperationLogCleanupResult = {
|
||||
mode: 'dry-run' | 'apply'
|
||||
cutoff: Date
|
||||
candidates: number
|
||||
deleted: number
|
||||
batchSize: number
|
||||
policy: OperationLogRetentionPolicy
|
||||
}
|
||||
|
||||
export function getOperationLogRetentionCutoff(
|
||||
now = new Date(),
|
||||
retentionDays = SYSTEM_FIELD_CHANGE_RETENTION_DAYS,
|
||||
) {
|
||||
if (!Number.isSafeInteger(retentionDays) || retentionDays < 1) {
|
||||
throw new Error('retentionDays must be a positive integer.')
|
||||
}
|
||||
return new Date(now.getTime() - retentionDays * DAY_MS)
|
||||
}
|
||||
|
||||
export function getOperationLogRetentionPolicy(): OperationLogRetentionPolicy {
|
||||
return {
|
||||
systemFieldChangeRetentionDays: SYSTEM_FIELD_CHANGE_RETENTION_DAYS,
|
||||
auditLogMinimumRetentionDays: AUDIT_LOG_MIN_RETENTION_DAYS,
|
||||
protectedCategories: [
|
||||
'user operations',
|
||||
'import records',
|
||||
'failed/retry logs',
|
||||
'non-product-archive logs',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
class OperationLogRetentionService {
|
||||
constructor(
|
||||
private readonly repository: OperationLogRetentionRepository =
|
||||
operationLogRetentionRepository,
|
||||
) {}
|
||||
|
||||
async cleanup(input: {
|
||||
now?: Date
|
||||
batchSize?: number
|
||||
apply?: boolean
|
||||
}): Promise<OperationLogCleanupResult> {
|
||||
const batchSize = normalizeBatchSize(input.batchSize)
|
||||
const cutoff = getOperationLogRetentionCutoff(input.now)
|
||||
const candidates = await this.repository.countEligible(cutoff)
|
||||
const mode = input.apply ? 'apply' : 'dry-run'
|
||||
|
||||
if (!input.apply || candidates === 0) {
|
||||
return {
|
||||
mode,
|
||||
cutoff,
|
||||
candidates,
|
||||
deleted: 0,
|
||||
batchSize,
|
||||
policy: getOperationLogRetentionPolicy(),
|
||||
}
|
||||
}
|
||||
|
||||
let deleted = 0
|
||||
while (true) {
|
||||
const ids = await this.repository.findEligibleIds({ cutoff, batchSize })
|
||||
if (ids.length === 0) break
|
||||
const deletedInBatch = await this.repository.deleteByIds(ids)
|
||||
if (deletedInBatch === 0) break
|
||||
deleted += deletedInBatch
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
cutoff,
|
||||
candidates,
|
||||
deleted,
|
||||
batchSize,
|
||||
policy: getOperationLogRetentionPolicy(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBatchSize(value?: number) {
|
||||
const batchSize = value ?? DEFAULT_OPERATION_LOG_CLEANUP_BATCH_SIZE
|
||||
if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 5000) {
|
||||
throw new Error('batchSize must be an integer between 1 and 5000.')
|
||||
}
|
||||
return batchSize
|
||||
}
|
||||
|
||||
export const operationLogRetentionService = new OperationLogRetentionService()
|
||||
|
||||
export { OperationLogRetentionService }
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
SUPPLIER_RECOMMENDED_PRODUCT_FIELDS,
|
||||
SUPPLIER_RECOMMENDED_PRODUCT_REQUIRED_KEYS,
|
||||
supplierRecommendedProductInputSchema,
|
||||
type SupplierRecommendedProductChange,
|
||||
type SupplierRecommendedProductFilters,
|
||||
@ -226,9 +227,18 @@ export class SupplierRecommendedProductsService {
|
||||
if (!rows.length) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据')
|
||||
if (rows.length > 5_000) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行')
|
||||
const importDate = currentImportDate()
|
||||
const requiredFields = SUPPLIER_RECOMMENDED_PRODUCT_FIELDS.filter((field) => SUPPLIER_RECOMMENDED_PRODUCT_REQUIRED_KEYS.includes(field.key))
|
||||
let created = 0
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
const values = parseInput({ ...rows[index], recommendationDate: importDate }, index + 2)
|
||||
const rowNumber = index + 2
|
||||
const rowValues = rows[index]
|
||||
const shippingMode = String(rowValues.shippingMode ?? '').trim()
|
||||
const missing = requiredFields
|
||||
.filter((field) => field.key !== 'dropShippingFee' || shippingMode.includes('代发'))
|
||||
.filter((field) => String(rowValues[field.key] ?? '').trim() === '')
|
||||
.map((field) => field.label)
|
||||
if (missing.length) throw new AppError(ErrorCode.VALIDATION_FAILED, `第${rowNumber}行:缺少必填字段:${missing.join('、')}`)
|
||||
const values = parseInput({ ...rowValues, recommendationDate: importDate }, rowNumber)
|
||||
await this.repository.importCreate(values, user, changeSet(null, values))
|
||||
created += 1
|
||||
onProgress?.({ processed: index + 1, total: rows.length, created, updated: 0 })
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user