Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-bom-estimate-agent-phase0

This commit is contained in:
WuYongJie 2026-09-03 15:38:14 +08:00
commit 33a13a8abb
19 changed files with 673 additions and 18 deletions

View File

@ -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` 的生产部署目标。

View File

@ -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",

View File

@ -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");

View File

@ -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")
}
// 家具系统发往统一通知中心的本地审计副本。

View 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 }

View File

@ -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

View File

@ -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 }))
})
})

View File

@ -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>

View File

@ -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> }

View File

@ -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 },
]

View File

@ -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' })
})
})

View File

@ -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}

View File

@ -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 = {

View File

@ -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

View File

@ -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 }

View File

@ -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

View File

@ -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'])
})
})

View File

@ -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 }

View File

@ -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 })