Compare commits
1 Commits
main
...
codex/wu-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15c082a712 |
@ -5,10 +5,6 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
# 数据库
|
||||
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DB_NAME?schema=public
|
||||
|
||||
# 员工工时花名只读人员配置来源(可选;开发环境配置后会合并正式人员配置)
|
||||
SUPPLY_CHAIN_PRODUCT_ARCHIVE_READ_DATABASE_URL=postgresql://READ_USER:READ_PASSWORD@READ_HOST:5432/READ_DB
|
||||
SUPPLY_CHAIN_PRODUCT_ARCHIVE_READ_DATABASE_SSL_REJECT_UNAUTHORIZED=true
|
||||
|
||||
# 产品全量档案的聚水潭 SQL Server 只读来源
|
||||
JST_REPORT_DB_HOST=
|
||||
JST_REPORT_DB_PORT=1433
|
||||
@ -78,15 +74,11 @@ JST_PLANNING_SQLSERVER_TRUST_SERVER_CERTIFICATE=true
|
||||
JWT_SECRET=replace-with-random-secret
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# 钉钉登录(OAuth 应用;只用于登录授权和用户信息获取)
|
||||
DINGTALK_LOGIN_CLIENT_ID=
|
||||
DINGTALK_LOGIN_CLIENT_SECRET=
|
||||
# 钉钉登录
|
||||
DINGTALK_CLIENT_ID=
|
||||
DINGTALK_CLIENT_SECRET=
|
||||
DINGTALK_CALLBACK_ORIGIN=
|
||||
|
||||
# 钉钉开放平台 API(通讯录、待办、审批等服务端调用)
|
||||
DINGTALK_API_CLIENT_ID=
|
||||
DINGTALK_API_CLIENT_SECRET=
|
||||
|
||||
# 钉钉待办:OA 阶段超时预警,以及预计出货日前 15 天的品牌策划主管提醒。
|
||||
DINGTALK_TODO_ENABLED=false
|
||||
DINGTALK_TODO_DETAIL_BASE_URL=
|
||||
@ -95,22 +87,6 @@ DINGTALK_TODO_ESCALATION_ENABLED=false
|
||||
DINGTALK_TODO_ESCALATION_DAYS=3
|
||||
# DINGTALK_AGENT_ID=
|
||||
|
||||
# 由壹点通知中心(仅服务端使用,项目密钥禁止提交或暴露给浏览器)
|
||||
YYD_NOTIFICATION_CENTER_URL=https://sc.jiansun.vip/notification-center
|
||||
YYD_NOTIFICATION_PROJECT_CODE=furniture_platform
|
||||
YYD_NOTIFICATION_PROJECT_SECRET=
|
||||
YYD_NOTIFICATION_TIMEOUT_MS=10000
|
||||
|
||||
# 店铺账号新增时,梦汐/扬灵群使用的钉钉“由壹点”群机器人。
|
||||
# webhook 地址和签名密钥只配置在本地/正式站运行环境,不要提交真实值。
|
||||
DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_URL=
|
||||
DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_SECRET=
|
||||
DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_TIMEOUT_MS=10000
|
||||
STORE_ACCOUNT_NOTIFICATION_RETRY_SECRET=
|
||||
|
||||
# 产品开发待办定时任务(仅服务端/部署 cron 使用,须与其他任务密钥分开)
|
||||
PRODUCT_DEVELOPMENT_TODO_CRON_SECRET=
|
||||
|
||||
# 对象存储
|
||||
# 必填,不能留空。当前生产桶使用 qiniu;只有明确切换适配器时才改为 s3/minio。
|
||||
STORAGE_PROVIDER=qiniu
|
||||
@ -159,26 +135,10 @@ BOM_COSTING_AI_MAX_TOKENS=12000
|
||||
BOM_COSTING_AI_REASONING_EFFORT=low
|
||||
BOM_COSTING_MODELING_PROVIDER=
|
||||
|
||||
# BOM 核算实时同款采购预估(1688 官方图搜)。
|
||||
# 网关固定为 https://skills-gateway.1688.com/api/find_product/1.0.0。
|
||||
# AK 只放服务器私密环境变量,服务端会按 x-csk-* 协议签名;实时同款价固定 × 90%
|
||||
# 是整件采购预估,历史 BOM 仅保留为结构参考。不要把 AK 写入 Git 或 NEXT_PUBLIC_* 变量。
|
||||
ALIBABA_1688_IMAGE_SEARCH_AK=replace-with-1688-image-search-ak
|
||||
# 仅在官方网关地址调整时设置;系统会拒绝非官方 HTTPS 地址。
|
||||
ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL=https://skills-gateway.1688.com/api/find_product/1.0.0
|
||||
ALIBABA_1688_IMAGE_SEARCH_TIMEOUT_MS=45000
|
||||
ALIBABA_1688_IMAGE_SEARCH_MIN_SIMILARITY=0.78
|
||||
ALIBABA_1688_IMAGE_SEARCH_PAGE_SIZE=10
|
||||
ALIBABA_1688_IMAGE_SEARCH_PURCHASE_AMOUNT=1
|
||||
ALIBABA_1688_IMAGE_SEARCH_TAGS=4306497
|
||||
|
||||
# 素材库白底图回传
|
||||
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_ENABLED=false
|
||||
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_BASE_URL=https://example.com
|
||||
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_KEY=replace-with-material-library-api-key
|
||||
# 检测报告查询(不填则复用上面素材平台的 BaseUrl / API Key;都没有时「是否有检测报告」显示为空)
|
||||
MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL=
|
||||
MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_KEY=
|
||||
|
||||
# 产品全量档案以图搜图(模型文件放在仓库外,不提交到 Git)
|
||||
IMAGE_SEARCH_MODEL_PATH=
|
||||
|
||||
8
furniture-3d-system/.gitignore
vendored
8
furniture-3d-system/.gitignore
vendored
@ -2,7 +2,6 @@
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/node_modules.pnpm-mixed
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
@ -16,7 +15,6 @@
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.next-*/
|
||||
/.next-oa-preview*/
|
||||
/.next-oa-webpack/
|
||||
/out/
|
||||
@ -73,9 +71,3 @@ typecheck-latest.log
|
||||
.local-test-*.log
|
||||
tsconfig.typecheck.generated.json
|
||||
local-packages/npm-cache/cache/_logs/
|
||||
|
||||
# 脚本监控 Agent 本机凭据
|
||||
agents/script-monitor/config.json
|
||||
|
||||
# 下载中心本地运行时产物
|
||||
.download-center/
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
# 供应链脚本监控 Agent
|
||||
|
||||
每台运行脚本的机器部署一份 Agent。Agent 只主动向产品平台发起 HTTP 请求,不需要为被监控机器开放入站端口。
|
||||
|
||||
## 接入
|
||||
|
||||
1. 在供应链中心的“脚本监控”页面点击“接入机器”,生成独立的 `agentId` 和 `agentToken`。
|
||||
2. 复制本目录到目标机器,把 `config.example.json` 复制为 `config.json`,填入凭据、内外网地址和脚本任务说明。
|
||||
3. 先运行 `node agent.mjs` 确认上报,再使用 `pm2 start ecosystem.config.cjs` 常驻。
|
||||
|
||||
需要在平台点击“立即运行”的脚本,必须在 `config.json` 对应任务中显式配置
|
||||
`"allowManualRun": true`。平台只会下发脚本 `key`,Agent 始终执行本机配置里预存的
|
||||
`command`,不会接收平台传来的任意命令。Agent 默认每 5 秒主动领取一次执行请求,
|
||||
因此内网机器不需要开放入站端口。
|
||||
|
||||
`connectionMode` 支持:
|
||||
|
||||
- `intranet`:只走内网地址。
|
||||
- `internet`:只走公网地址,生产必须使用 HTTPS。
|
||||
- `auto`:优先内网,失败后自动尝试公网。
|
||||
|
||||
## 记录真实运行结果
|
||||
|
||||
对于需要记录开始时间、耗时、退出码和输出的任务,用包装器代替原命令:
|
||||
|
||||
```bash
|
||||
node /opt/furniture-script-monitor/run-script.mjs supply-chain-archive-sync
|
||||
```
|
||||
|
||||
包装器执行 `config.json` 中同一 `key` 的 `command`,并在开始与结束时上报。Agent Token 只保存在目标机器的 `config.json`,该文件已被仓库忽略。
|
||||
@ -1,410 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { hostname, platform, release } from 'node:os'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
export const AGENT_VERSION = '1.0.0'
|
||||
const AGENT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function requiredText(value, label) {
|
||||
if (typeof value !== 'string' || !value.trim()) throw new Error(`缺少 ${label}`)
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
export function loadConfig(configPath = process.env.SCRIPT_MONITOR_CONFIG || resolve(AGENT_DIR, 'config.json')) {
|
||||
if (!existsSync(configPath)) {
|
||||
throw new Error(`配置文件不存在:${configPath},请复制 config.example.json 为 config.json`)
|
||||
}
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf8'))
|
||||
config.agentId = requiredText(config.agentId, 'agentId')
|
||||
config.agentToken = requiredText(config.agentToken, 'agentToken')
|
||||
config.connectionMode = String(config.connectionMode || 'auto').toLowerCase()
|
||||
if (!['intranet', 'internet', 'auto'].includes(config.connectionMode)) {
|
||||
throw new Error('connectionMode 必须是 intranet、internet 或 auto')
|
||||
}
|
||||
config.reportIntervalSec = Math.max(15, Number(config.reportIntervalSec) || 60)
|
||||
config.requestTimeoutMs = Math.max(1000, Number(config.requestTimeoutMs) || 10000)
|
||||
config.commandPollIntervalSec = Math.min(60, Math.max(2, Number(config.commandPollIntervalSec) || 5))
|
||||
config.scripts = Array.isArray(config.scripts) ? config.scripts : []
|
||||
return config
|
||||
}
|
||||
|
||||
function normalizeCenterUrl(value) {
|
||||
if (typeof value !== 'string' || !value.trim()) return null
|
||||
const url = new URL(value.trim())
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`不支持的中心地址协议:${url.protocol}`)
|
||||
return url.toString().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function resolveCenterEndpoints(config) {
|
||||
const intranet = normalizeCenterUrl(config.centerUrls?.intranet)
|
||||
const internet = normalizeCenterUrl(config.centerUrls?.internet)
|
||||
const endpoints = []
|
||||
if (config.connectionMode !== 'internet' && intranet) endpoints.push({ kind: 'INTRANET', url: intranet })
|
||||
if (config.connectionMode !== 'intranet' && internet) endpoints.push({ kind: 'INTERNET', url: internet })
|
||||
if (endpoints.length === 0) throw new Error('当前连接模式没有可用的 centerUrls 地址')
|
||||
return endpoints
|
||||
}
|
||||
|
||||
export async function postToCenter(config, route, payload) {
|
||||
let lastError = null
|
||||
for (const endpoint of resolveCenterEndpoints(config)) {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs)
|
||||
try {
|
||||
const response = await fetch(`${endpoint.url}${route}`, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${config.agentToken}`,
|
||||
'x-script-monitor-agent-id': config.agentId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
...(route.endsWith('/report')
|
||||
? { connectionKind: payload.connectionKind ?? endpoint.kind }
|
||||
: {}),
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => '')
|
||||
throw new Error(`HTTP ${response.status}${message ? `:${message.slice(0, 240)}` : ''}`)
|
||||
}
|
||||
return { endpoint, body: await response.json().catch(() => null) }
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error('没有可用的中心地址')
|
||||
}
|
||||
|
||||
function hashKey(value) {
|
||||
return createHash('sha256').update(value).digest('hex').slice(0, 20)
|
||||
}
|
||||
|
||||
function redactCommand(command) {
|
||||
return String(command || '')
|
||||
.replace(/([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY)[A-Z0-9_]*=)([^\s]+)/gi, '$1[REDACTED]')
|
||||
.replace(/(https?:\/\/[^:/\s]+:)[^@/\s]+@/gi, '$1[REDACTED]@')
|
||||
.slice(0, 2000)
|
||||
}
|
||||
|
||||
function readPm2() {
|
||||
try {
|
||||
const raw = execFileSync('pm2', ['jlist'], { encoding: 'utf8', timeout: 5000 })
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function scanPm2() {
|
||||
return readPm2().map((processInfo) => {
|
||||
const name = String(processInfo.name || '未命名 PM2 进程')
|
||||
const status = processInfo.pm2_env?.status === 'online'
|
||||
? 'RUNNING'
|
||||
: processInfo.pm2_env?.status === 'stopped'
|
||||
? 'DISABLED'
|
||||
: processInfo.pm2_env?.status === 'errored'
|
||||
? 'FAILED'
|
||||
: 'UNKNOWN'
|
||||
return {
|
||||
key: `pm2:${name}`,
|
||||
name,
|
||||
taskDescription: '',
|
||||
jobType: 'PM2',
|
||||
schedule: null,
|
||||
commandPreview: redactCommand(processInfo.pm2_env?.pm_exec_path || ''),
|
||||
expectedIntervalSec: null,
|
||||
status,
|
||||
isEnabled: status !== 'DISABLED',
|
||||
canManualRun: false,
|
||||
lastStartedAt: processInfo.pm2_env?.pm_uptime
|
||||
? new Date(processInfo.pm2_env.pm_uptime).toISOString()
|
||||
: null,
|
||||
lastResultSummary: status === 'RUNNING'
|
||||
? `PM2 在线,PID ${processInfo.pid || '-'}`
|
||||
: `PM2 状态:${processInfo.pm2_env?.status || 'unknown'}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parseCronLine(line) {
|
||||
const value = line.trim()
|
||||
if (!value || value.startsWith('#')) return null
|
||||
const special = value.match(/^@(reboot|yearly|annually|monthly|weekly|daily|hourly)\s+(.+)$/)
|
||||
const regular = value.match(/^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.+)$/)
|
||||
const schedule = special ? `@${special[1]}` : regular?.[1]
|
||||
const command = special?.[2] ?? regular?.[2]
|
||||
if (!schedule || !command) return null
|
||||
const tag = command.match(/#\s*([\w.-]{3,})\s*$/)?.[1]
|
||||
const name = tag || command.split(/\s+/).filter(Boolean).slice(-1)[0] || 'cron task'
|
||||
return {
|
||||
key: `cron:${hashKey(`${schedule}\n${command}`)}`,
|
||||
name,
|
||||
taskDescription: '',
|
||||
jobType: 'CRON',
|
||||
schedule,
|
||||
commandPreview: redactCommand(command.replace(/\s+#.*$/, '')),
|
||||
expectedIntervalSec: null,
|
||||
status: 'UNKNOWN',
|
||||
isEnabled: true,
|
||||
canManualRun: false,
|
||||
}
|
||||
}
|
||||
|
||||
function scanCron() {
|
||||
if (platform() === 'win32') return []
|
||||
try {
|
||||
const raw = execFileSync('crontab', ['-l'], { encoding: 'utf8', timeout: 5000 })
|
||||
return raw.split(/\r?\n/).map(parseCronLine).filter(Boolean)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function checkProcess(name) {
|
||||
try {
|
||||
if (platform() === 'win32') {
|
||||
const output = execFileSync('tasklist', ['/FI', `IMAGENAME eq ${name}`], { encoding: 'utf8', timeout: 4000 })
|
||||
return output.toLowerCase().includes(String(name).toLowerCase())
|
||||
}
|
||||
execFileSync('pgrep', ['-f', '--', name], { stdio: 'ignore', timeout: 4000 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function checkConfiguredScript(script) {
|
||||
if (script.enabled === false) return { status: 'DISABLED', summary: '监控已停用' }
|
||||
const check = script.resultCheck
|
||||
if (!check) return { status: 'UNKNOWN', summary: null }
|
||||
|
||||
if (check.type === 'file') {
|
||||
try {
|
||||
const stat = statSync(requiredText(check.path, 'resultCheck.path'))
|
||||
const ageSec = Math.max(0, Math.round((Date.now() - stat.mtimeMs) / 1000))
|
||||
const fresh = !check.maxAgeSec || ageSec <= Number(check.maxAgeSec)
|
||||
const largeEnough = check.minBytes == null || stat.size >= Number(check.minBytes)
|
||||
return {
|
||||
status: fresh && largeEnough ? 'SUCCESS' : 'FAILED',
|
||||
summary: `文件 ${stat.size} B,${ageSec} 秒前更新`,
|
||||
}
|
||||
} catch (error) {
|
||||
return { status: 'FAILED', summary: `文件检查失败:${error.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
if (check.type === 'systemd') {
|
||||
try {
|
||||
const service = requiredText(check.service, 'resultCheck.service')
|
||||
const state = execFileSync('systemctl', ['is-active', service], { encoding: 'utf8', timeout: 4000 }).trim()
|
||||
return { status: state === 'active' ? 'RUNNING' : 'FAILED', summary: `systemd:${state}` }
|
||||
} catch {
|
||||
return { status: 'FAILED', summary: 'systemd 服务未运行' }
|
||||
}
|
||||
}
|
||||
|
||||
if (check.type === 'process') {
|
||||
const running = checkProcess(requiredText(check.name, 'resultCheck.name'))
|
||||
return { status: running ? 'RUNNING' : 'FAILED', summary: running ? '进程正在运行' : '未找到目标进程' }
|
||||
}
|
||||
|
||||
return { status: 'UNKNOWN', summary: '未识别的结果检查类型' }
|
||||
}
|
||||
|
||||
function normalizeConfiguredScript(script) {
|
||||
const key = requiredText(script.key, 'scripts[].key')
|
||||
const result = checkConfiguredScript(script)
|
||||
return {
|
||||
key,
|
||||
name: requiredText(script.name, `scripts[${key}].name`),
|
||||
taskDescription: String(script.taskDescription || '').slice(0, 2000),
|
||||
jobType: String(script.jobType || 'OTHER').toUpperCase(),
|
||||
schedule: script.schedule ? String(script.schedule).slice(0, 240) : null,
|
||||
commandPreview: redactCommand(script.commandPreview || script.command || ''),
|
||||
expectedIntervalSec: script.expectedIntervalSec ? Number(script.expectedIntervalSec) : null,
|
||||
status: result.status,
|
||||
isEnabled: script.enabled !== false,
|
||||
canManualRun: script.allowManualRun === true && Boolean(script.command),
|
||||
lastResultSummary: result.summary,
|
||||
}
|
||||
}
|
||||
|
||||
export function collectScripts(config) {
|
||||
const byKey = new Map()
|
||||
if (config.autoDiscover?.cron !== false) {
|
||||
for (const script of scanCron()) byKey.set(script.key, script)
|
||||
}
|
||||
if (config.autoDiscover?.pm2 !== false) {
|
||||
for (const script of scanPm2()) byKey.set(script.key, script)
|
||||
}
|
||||
for (const script of config.scripts) {
|
||||
const normalized = normalizeConfiguredScript(script)
|
||||
byKey.set(normalized.key, { ...(byKey.get(normalized.key) || {}), ...normalized })
|
||||
}
|
||||
return [...byKey.values()]
|
||||
}
|
||||
|
||||
export async function reportSnapshot(config) {
|
||||
const scripts = collectScripts(config)
|
||||
const result = await postToCenter(config, '/api/internal/script-monitor/report', {
|
||||
hostname: hostname(),
|
||||
os: `${platform()} ${release()}`,
|
||||
agentVersion: AGENT_VERSION,
|
||||
reportIntervalSec: config.reportIntervalSec,
|
||||
scripts,
|
||||
})
|
||||
console.log(`[script-monitor] 已通过${result.endpoint.kind === 'INTRANET' ? '内网' : '公网'}上报 ${scripts.length} 个脚本`)
|
||||
}
|
||||
|
||||
function keepTail(current, chunk, limit = 12000) {
|
||||
const next = current + chunk.toString()
|
||||
return next.length > limit ? next.slice(-limit) : next
|
||||
}
|
||||
|
||||
async function reportManualRun(config, command, input) {
|
||||
return postToCenter(config, '/api/internal/script-monitor/run', {
|
||||
commandId: command.id,
|
||||
scriptKey: command.scriptKey,
|
||||
scriptName: command.scriptName,
|
||||
taskDescription: command.taskDescription || '',
|
||||
jobType: String(command.jobType || 'OTHER').toUpperCase(),
|
||||
...input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function executeManualCommand(config, command) {
|
||||
const script = config.scripts.find((item) => item.key === command.scriptKey)
|
||||
const startedAt = new Date()
|
||||
const externalRunId = `manual:${command.id}`
|
||||
if (!script?.command || script.allowManualRun !== true) {
|
||||
const message = 'Agent 配置未开放该脚本的手动运行权限'
|
||||
await reportManualRun(config, command, {
|
||||
externalRunId,
|
||||
status: 'FAILED',
|
||||
trigger: 'manual',
|
||||
startedAt: startedAt.toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
durationMs: 0,
|
||||
exitCode: 1,
|
||||
resultSummary: message,
|
||||
errorTail: message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await reportManualRun(config, command, {
|
||||
externalRunId,
|
||||
status: 'RUNNING',
|
||||
trigger: 'manual',
|
||||
startedAt: startedAt.toISOString(),
|
||||
})
|
||||
let outputTail = ''
|
||||
let errorTail = ''
|
||||
try {
|
||||
const child = spawn(script.command, {
|
||||
cwd: script.cwd ? resolve(script.cwd) : process.cwd(),
|
||||
env: process.env,
|
||||
shell: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
child.stdout.on('data', (chunk) => {
|
||||
outputTail = keepTail(outputTail, chunk)
|
||||
process.stdout.write(chunk)
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
errorTail = keepTail(errorTail, chunk)
|
||||
process.stderr.write(chunk)
|
||||
})
|
||||
const exitCode = await new Promise((resolveExit, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', (code) => resolveExit(code ?? 1))
|
||||
})
|
||||
const finishedAt = new Date()
|
||||
await reportManualRun(config, command, {
|
||||
externalRunId,
|
||||
status: exitCode === 0 ? 'SUCCESS' : 'FAILED',
|
||||
trigger: 'manual',
|
||||
startedAt: startedAt.toISOString(),
|
||||
finishedAt: finishedAt.toISOString(),
|
||||
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
||||
exitCode,
|
||||
resultSummary: exitCode === 0 ? '手动运行成功' : `手动运行失败,退出码 ${exitCode}`,
|
||||
outputTail,
|
||||
errorTail,
|
||||
})
|
||||
} catch (error) {
|
||||
const finishedAt = new Date()
|
||||
await reportManualRun(config, command, {
|
||||
externalRunId,
|
||||
status: 'FAILED',
|
||||
trigger: 'manual',
|
||||
startedAt: startedAt.toISOString(),
|
||||
finishedAt: finishedAt.toISOString(),
|
||||
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
||||
exitCode: 1,
|
||||
resultSummary: `手动运行启动失败:${error.message}`,
|
||||
errorTail: error.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollManualCommand(config) {
|
||||
const result = await postToCenter(
|
||||
config,
|
||||
'/api/internal/script-monitor/commands/claim',
|
||||
{},
|
||||
)
|
||||
const command = result.body?.data?.command
|
||||
if (!command) return false
|
||||
await executeManualCommand(config, command)
|
||||
return true
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig()
|
||||
const run = async () => {
|
||||
try {
|
||||
await reportSnapshot(config)
|
||||
} catch (error) {
|
||||
console.error('[script-monitor] 上报失败:', error.message)
|
||||
}
|
||||
}
|
||||
let commandBusy = false
|
||||
const poll = async () => {
|
||||
if (commandBusy) return
|
||||
commandBusy = true
|
||||
try {
|
||||
await pollManualCommand(config)
|
||||
} catch (error) {
|
||||
console.error('[script-monitor] 手动任务轮询失败:', error.message)
|
||||
} finally {
|
||||
commandBusy = false
|
||||
}
|
||||
}
|
||||
await run()
|
||||
await poll()
|
||||
const timer = setInterval(run, config.reportIntervalSec * 1000)
|
||||
const commandTimer = setInterval(poll, config.commandPollIntervalSec * 1000)
|
||||
const stop = () => {
|
||||
clearInterval(timer)
|
||||
clearInterval(commandTimer)
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGINT', stop)
|
||||
process.on('SIGTERM', stop)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
main().catch((error) => {
|
||||
console.error('[script-monitor] 启动失败:', error.message)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { executeManualCommand } from './agent.mjs'
|
||||
|
||||
function config(overrides = {}) {
|
||||
return {
|
||||
agentId: 'agent-1',
|
||||
agentToken: 'secret-token',
|
||||
connectionMode: 'internet',
|
||||
centerUrls: { internet: 'https://monitor.example.test' },
|
||||
requestTimeoutMs: 5000,
|
||||
scripts: [{
|
||||
key: 'local-check',
|
||||
name: '本机检查',
|
||||
taskDescription: '验证手动触发',
|
||||
jobType: 'MANUAL',
|
||||
allowManualRun: true,
|
||||
command: `${JSON.stringify(process.execPath)} -e "console.log('manual-ok')"`,
|
||||
}],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function command() {
|
||||
return {
|
||||
id: 'command-1',
|
||||
scriptKey: 'local-check',
|
||||
scriptName: '本机检查',
|
||||
taskDescription: '验证手动触发',
|
||||
jobType: 'MANUAL',
|
||||
}
|
||||
}
|
||||
|
||||
test('manual command executes only the local configured command and reports its result', async () => {
|
||||
const previousFetch = globalThis.fetch
|
||||
const reports = []
|
||||
globalThis.fetch = async (_url, init) => {
|
||||
reports.push(JSON.parse(init.body))
|
||||
return new Response(JSON.stringify({ success: true, data: { ok: true } }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
try {
|
||||
await executeManualCommand(config(), command())
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch
|
||||
}
|
||||
|
||||
assert.equal(reports.length, 2)
|
||||
assert.equal(reports[0].status, 'RUNNING')
|
||||
assert.equal(reports[1].status, 'SUCCESS')
|
||||
assert.equal(reports[1].exitCode, 0)
|
||||
assert.match(reports[1].outputTail, /manual-ok/)
|
||||
assert.equal(reports[1].commandId, 'command-1')
|
||||
})
|
||||
|
||||
test('manual command is rejected when allowManualRun is not explicitly enabled', async () => {
|
||||
const previousFetch = globalThis.fetch
|
||||
const reports = []
|
||||
globalThis.fetch = async (_url, init) => {
|
||||
reports.push(JSON.parse(init.body))
|
||||
return new Response(JSON.stringify({ success: true, data: { ok: true } }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
const blockedConfig = config()
|
||||
blockedConfig.scripts[0].allowManualRun = false
|
||||
try {
|
||||
await executeManualCommand(blockedConfig, command())
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch
|
||||
}
|
||||
|
||||
assert.equal(reports.length, 1)
|
||||
assert.equal(reports[0].status, 'FAILED')
|
||||
assert.match(reports[0].resultSummary, /未开放/)
|
||||
})
|
||||
@ -1,36 +0,0 @@
|
||||
{
|
||||
"agentId": "PASTE_AGENT_ID_HERE",
|
||||
"agentToken": "PASTE_AGENT_TOKEN_HERE",
|
||||
"agentName": "仓库服务器 01",
|
||||
"connectionMode": "auto",
|
||||
"centerUrls": {
|
||||
"intranet": "http://10.0.0.20:3001",
|
||||
"internet": "https://cp.jiansun.vip"
|
||||
},
|
||||
"reportIntervalSec": 60,
|
||||
"commandPollIntervalSec": 5,
|
||||
"requestTimeoutMs": 10000,
|
||||
"autoDiscover": {
|
||||
"cron": true,
|
||||
"pm2": true
|
||||
},
|
||||
"scripts": [
|
||||
{
|
||||
"key": "supply-chain-archive-sync",
|
||||
"name": "供应链产品档案同步",
|
||||
"taskDescription": "同步聚水潭商品档案、仓库和供应商扩展字段",
|
||||
"jobType": "CRON",
|
||||
"schedule": "*/15 * * * *",
|
||||
"expectedIntervalSec": 900,
|
||||
"allowManualRun": true,
|
||||
"command": "npm run sync:supply-chain-product-archive",
|
||||
"cwd": "/opt/furniture-3d-system-prod/current",
|
||||
"resultCheck": {
|
||||
"type": "file",
|
||||
"path": "/opt/furniture-3d-system-prod/shared/logs/product-archive-sync.log",
|
||||
"maxAgeSec": 1800,
|
||||
"minBytes": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'furniture-script-monitor-agent',
|
||||
script: 'agent.mjs',
|
||||
cwd: __dirname,
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
SCRIPT_MONITOR_CONFIG: `${__dirname}/config.json`,
|
||||
},
|
||||
}],
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { resolve } from 'node:path'
|
||||
import { loadConfig, postToCenter } from './agent.mjs'
|
||||
|
||||
function keepTail(current, chunk, limit = 12000) {
|
||||
const next = current + chunk.toString()
|
||||
return next.length > limit ? next.slice(-limit) : next
|
||||
}
|
||||
|
||||
async function sendRun(config, script, input) {
|
||||
return postToCenter(config, '/api/internal/script-monitor/run', {
|
||||
scriptKey: script.key,
|
||||
scriptName: script.name,
|
||||
taskDescription: script.taskDescription || '',
|
||||
jobType: String(script.jobType || 'OTHER').toUpperCase(),
|
||||
...input,
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const scriptKey = process.argv[2]
|
||||
if (!scriptKey) throw new Error('用法:node run-script.mjs <script-key>')
|
||||
const config = loadConfig()
|
||||
const script = config.scripts.find((item) => item.key === scriptKey)
|
||||
if (!script) throw new Error(`配置中不存在脚本:${scriptKey}`)
|
||||
if (!script.command) throw new Error(`脚本 ${scriptKey} 未配置 command`)
|
||||
|
||||
const externalRunId = `${scriptKey}:${randomUUID()}`
|
||||
const startedAt = new Date()
|
||||
await sendRun(config, script, {
|
||||
externalRunId,
|
||||
status: 'RUNNING',
|
||||
trigger: 'wrapper',
|
||||
startedAt: startedAt.toISOString(),
|
||||
}).catch((error) => {
|
||||
console.warn('[script-monitor-run] 开始状态上报失败,不阻断原脚本:', error.message)
|
||||
})
|
||||
|
||||
let outputTail = ''
|
||||
let errorTail = ''
|
||||
const child = spawn(script.command, {
|
||||
cwd: script.cwd ? resolve(script.cwd) : process.cwd(),
|
||||
env: process.env,
|
||||
shell: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
child.stdout.on('data', (chunk) => {
|
||||
outputTail = keepTail(outputTail, chunk)
|
||||
process.stdout.write(chunk)
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
errorTail = keepTail(errorTail, chunk)
|
||||
process.stderr.write(chunk)
|
||||
})
|
||||
|
||||
const exitCode = await new Promise((resolveExit, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', (code) => resolveExit(code ?? 1))
|
||||
})
|
||||
const finishedAt = new Date()
|
||||
await sendRun(config, script, {
|
||||
externalRunId,
|
||||
status: exitCode === 0 ? 'SUCCESS' : 'FAILED',
|
||||
trigger: 'wrapper',
|
||||
startedAt: startedAt.toISOString(),
|
||||
finishedAt: finishedAt.toISOString(),
|
||||
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
||||
exitCode,
|
||||
resultSummary: exitCode === 0 ? '脚本执行成功' : `脚本退出码 ${exitCode}`,
|
||||
outputTail,
|
||||
errorTail,
|
||||
}).catch((error) => {
|
||||
console.warn('[script-monitor-run] 运行结果上报失败,保留原退出码:', error.message)
|
||||
})
|
||||
process.exitCode = exitCode
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[script-monitor-run] 执行失败:', error.message)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@ -1,153 +0,0 @@
|
||||
# 财务系统店铺密码直接显示契约
|
||||
|
||||
状态:生产接口已上线;2026-09-09 按当前明文存储规则修正 `passwordConfigured`。
|
||||
|
||||
## 存储事实与授权边界
|
||||
|
||||
- 密码真相源是 `public."StoreAccount"."passwordCiphertext"`。字段名是历史遗留;当前规则将新值以明文保存,读取链路对历史 `v1:` AES-256-GCM 值保留兼容。
|
||||
- 存量 `v1:` 值已由 `scripts/migrate-store-account-plaintext-20260908.js` 迁移。`STORE_ACCOUNT_ENCRYPTION_KEY` 仅用于可能残留或恢复出的历史 `v1:` 值,不参与当前明文值读取。
|
||||
- `passwordConfigured` 只表示存储值是否非空,不校验字符集或密码强度;因此包含 `+`、`/`、`=` 等字符的密码也会正确返回 true。它不返回密码本身。
|
||||
- 普通 `store_account_reader` 可读视图、不能读取基表或密码列。此次迁移不增加其基表权限。
|
||||
- 本接口只信任专用服务身份 `finance-store-accounts`。财务系统必须在自身后端校验用户登录态、查看/复制权限及店铺范围,然后从可信会话中取 `requesterId`。不得把客户端传入的任意人员 ID 当成操作者。服务凭据仅保存在财务后端,禁止交给浏览器。
|
||||
- 不提供密码列表或批量导出。财务页面可在已通过自身页面权限校验后,自动读取当前可见页最多 50 个账号;CSV 和共享镜像仍只读取 `passwordConfigured`。复制操作继续走单账号接口。上游审计记录的用户 ID 是专用财务服务的身份断言,未授权请求中的 ID 不可信,`serviceId=null`。
|
||||
|
||||
## HTTP 契约
|
||||
|
||||
生产配置完成后的地址:
|
||||
|
||||
`POST https://cp.jiansun.vip/internal/store-accounts/{id}/password/reveal`
|
||||
|
||||
`POST https://cp.jiansun.vip/internal/store-accounts/visible-page/password/reveal`
|
||||
|
||||
`id` 为 `accounts_v1.id`,仅接受单个 1–128 位字母、数字、下划线或连字符。无查询参数;禁止在 URL、请求体、请求头中传待读取密码。
|
||||
|
||||
请求头:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <专用服务令牌>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求体(严格拒绝额外字段,最大 1024 字节,1 秒内读取完毕):
|
||||
|
||||
```json
|
||||
{ "requesterId": "finance-user-123", "purpose": "view" }
|
||||
```
|
||||
|
||||
`purpose` 仅允许 `view` 或 `copy`。`requesterId` 是财务会话中的稳定人员标识,1–128 位 `[A-Za-z0-9_.:@-]`。不接受批量 ID、查询参数、普通登录 Cookie/JWT、普通数据库凭据或其他内部任务密钥。
|
||||
|
||||
当前页批量请求严格只接受以下字段,正文最大 8192 字节;`accountIds` 为 1 至 50 个互不重复且符合单账号 ID 规则的值:
|
||||
|
||||
```json
|
||||
{ "requesterId": "finance-user-123", "purpose": "view", "accountIds": ["account-1", "account-2"] }
|
||||
```
|
||||
|
||||
成功 `200`:
|
||||
|
||||
```json
|
||||
{ "requestId": "<服务端生成的 UUID>", "password": "<仅本次响应体中的密码>" }
|
||||
```
|
||||
|
||||
当前页批量成功的 HTTP 状态为 `200`,每个账号分别返回 `200/404/429/503`。每项都有独立 UUID;只有状态 `200` 的项包含密码,并按请求顺序完整返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "<批次 UUID>",
|
||||
"items": [
|
||||
{ "accountId": "account-1", "requestId": "<项目 UUID>", "status": 200, "password": "<密码>" },
|
||||
{ "accountId": "account-2", "requestId": "<项目 UUID>", "status": 404 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
所有状态均返回 `Cache-Control: no-store`、`Pragma: no-cache`、`X-Content-Type-Options: nosniff`。`X-Request-Id` 与响应体、审计的追踪 ID 相同。不会在响应头中放密码。
|
||||
|
||||
错误体:
|
||||
|
||||
```json
|
||||
{ "requestId": "<UUID>", "error": { "code": "FORBIDDEN", "message": "请求被拒绝" } }
|
||||
```
|
||||
|
||||
| HTTP | code | 含义 |
|
||||
|---|---|---|
|
||||
| 403 | FORBIDDEN | 服务认证失败、非 HTTPS、非法 ID/JSON/用途、额外参数或非 POST |
|
||||
| 404 | PASSWORD_NOT_FOUND | 账号不存在或没有可用密码,两者不区分 |
|
||||
| 429 | RATE_LIMITED | 任一限流窗口超限 |
|
||||
| 503 | UNAVAILABLE | 密钥/解密、数据库、限流或审计不可用;不返回内部异常 |
|
||||
|
||||
审计或数据库故障优先返回 503,绝不在无法持久化成功审计时返回密码。数据库完全不可用时无法保存该次失败审计,由网关仅记录追踪 ID、状态与时间供运维关联。成功审计表示服务器已授权并生成响应,不等同于浏览器已经复制成功。
|
||||
|
||||
## 限流和超时
|
||||
|
||||
- 单账号数据库滚动窗口:服务全部账号 30 次/分钟、300 次/24 小时;同一人员 10 次/分钟;同一账号 5 次/分钟。公网 TLS 网关保留 1 KiB 敏感请求正文上限,财务端把 50 行当前页拆成两个最多 25 个账号的批次;批次标记因此按服务 20 次/分钟、1000 次/24 小时、同一人员 10 次/分钟限流,等价于原每页额度。批次中的每个账号仍受同一账号 5 次/分钟限制。查看和复制共享账号额度,轮换令牌不重置服务身份或额度。
|
||||
- 已认证的成功、404、参数拒绝和解密失败均占额度;429 不延长窗口。额度检查与审计在同一数据库事务中用事务级锁串行化,跨进程/重启共享状态。
|
||||
- 429 不携带剩余数量或精确窗口,避免推测其他用户行为。客户端不得自动重试密码读取;提示用户稍后再试,持续超限联系管理员。
|
||||
- 财务客户端设置总超时 10 秒、禁用自动重定向和重试。后端池上限 4、连接获取 1 秒、SQL 执行 1.5 秒/客户端查询 2 秒、锁等待 1 秒,路由运行预算 10 秒。网关必须配置请求体 1 秒和总请求截止,防止慢请求占用。
|
||||
- 入口网关还需按来源 IP 对全部请求(包括未授权请求)限制为 60 次/分钟,返回 429 JSON 和 `no-store`;这项入口防滥用配置属于上线门禁。应用层数据库限流负责已认证的敏感读取。
|
||||
|
||||
## 环境变量(仅名称及要求)
|
||||
|
||||
| 名称 | 所在系统与要求 |
|
||||
|---|---|
|
||||
| STORE_ACCOUNT_ENCRYPTION_KEY | 仅用于兼容历史 `v1:` 密文,至少 32 字符;生产不回退到 JWT_SECRET |
|
||||
| STORE_ACCOUNT_FINANCE_TOKEN | 上游专用服务令牌,密码学随机至少 32 字节,base64url 编码,43–128 字符;缺失时拒绝全部调用 |
|
||||
| STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS | 上游可选旧令牌,仅用于短时轮换;主令牌缺失时旧令牌也不生效 |
|
||||
| STORE_ACCOUNT_REVEAL_EDGE_TOKEN | 上游与可信 TLS 网关之间的独立随机凭据,base64url 43–128 字符;与财务令牌完全分离,不提供给财务系统;缺失时拒绝全部调用 |
|
||||
| DATABASE_URL | 上游现有数据库连接;仅专用不输出查询/异常的连接池读取密文、写审计 |
|
||||
| STORE_ACCOUNT_REVEAL_URL | 财务后端配置完整 HTTPS 地址模板 |
|
||||
| STORE_ACCOUNT_REVEAL_VISIBLE_PAGE_URL | 财务后端配置当前页批量 HTTPS 地址;必须使用精确 `visible-page` 路径 |
|
||||
| STORE_ACCOUNT_REVEAL_TOKEN | 财务后端保存与上游专用令牌对应的值 |
|
||||
| STORE_REVEAL_TEST_ADMIN_URL | 仅本地集成测试;必须指向回环地址、有建测试库/角色权限的 PostgreSQL |
|
||||
| STORE_REVEAL_TEST_POSTGRES_CONTAINER | 仅本地日志验收,指定本地 PostgreSQL 容器以只读扫描测试期间日志 |
|
||||
|
||||
## HTTPS、日志和缓存上线门禁
|
||||
|
||||
1. 财务后端至公开入口及跨主机的所有代理链路必须使用验证证书的 HTTPS,禁止 HTTP 跳转、跳过证书验证、明文 FRP/跨主机上游。当前 PR 不修改生产 TLS 拓扑;上线前逐跳核实,不满足即不启用令牌。
|
||||
2. 应用不以 URL 或未经认证的转发头证明 TLS。必须由受控网关在实际 HTTPS 入口覆盖 `X-Forwarded-Proto: https` 及 `X-Store-Reveal-Edge-Token`(值来自独立 `STORE_ACCOUNT_REVEAL_EDGE_TOKEN`),剥除客户端原有同名头;HTTP 入口直接拒绝,不能注入上述凭据。应用对网关凭据做定时安全比较,缺失/错误或协议不是 https 时拒绝。凭据仅上游和网关持有,不提供给财务系统;配置文件 0600,不通过 `nginx -T` 等诊断输出展示。仍需封闭应用直连并逐跳验证 TLS,独立凭据不代替网络加密。
|
||||
3. 对精确路径 `/internal/store-accounts/*/password/reveal` 禁止网关缓存、响应落盘缓冲、CDN缓存、请求/响应体及 Authorization 采集;包括 APM、WAF、异常上报、链路追踪和调试抓包。网关日志只允许时间、方法、路径(不带参数)、状态、耗时、追踪 ID。不要采集响应对象。
|
||||
4. 网关自己的 403/404/429/503 也使用脱敏 JSON、`Cache-Control: no-store` 和追踪 ID;不得返回上游诊断页。禁止将该路由接入通用 `withApi`/request logger;目前路由直接返回白名单响应,不记录异常对象。
|
||||
5. 数据库查询结果包含当前明文密码;严禁记录 SQL 结果、绑定参数或异常对象,不要启用该连接池的参数/结果追踪。密码写入及历史格式迁移期间同样关闭 PostgreSQL/pgAudit 的绑定参数和错误参数记录。
|
||||
6. 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器在进入已授权页面后只读取当前可见页,避免 localStorage/sessionStorage/Service Worker 持久化;页面隐藏或离开时清除页面密码,不把密码放入通知、埋点或消息队列。
|
||||
|
||||
## 视图与审计迁移
|
||||
|
||||
初始迁移:`prisma/migrations/20260908120000_store_account_password_reveal/migration.sql`。明文状态修复:`prisma/migrations/20260909100000_fix_store_account_password_configured_plaintext/migration.sql`。
|
||||
|
||||
- 新增 `StoreAccountPasswordAccessAudit`,固定字段为请求追踪 ID、人员标识、服务身份、账号 ID、用途、HTTP状态、数据库时间;不关联用户/店铺外键,以保留未授权、不存在和删除账号的失败审计。状态 200 表示成功,其他状态表示失败。
|
||||
- 只在原视图存在时,以原视图查询作为子查询,在末尾追加 `passwordConfigured boolean`,保留原字段、筛选、每行内容、ID、所有者与 ACL;`EXISTS` 不会产生重复行。未配置外部视图的环境不擅自创建外部读入口。
|
||||
- `passwordConfigured` 的当前定义为 `passwordCiphertext <> ''`。修复迁移仅替换这个布尔表达式,并在事务内核对原字段逐行内容、视图所有者和 ACL 未变;不读取或输出密码。
|
||||
- 明文迁移已由独立脚本完成。本状态修复不改任何密码或银行账号数据,也不增加普通只读账号权限。审计数据至少保留 365 天。
|
||||
|
||||
迁移前由 DBA 只读确认目标库、视图定义/所有者/ACL、字段顺序、行数与唯一性、密码聚合、普通读取账号和其成员角色权限。若生产仍以本次基线验收,COUNT 与 DISTINCT id 必须均为 958;若业务新增/删除则暂停并重新确认基线,不改数据凑数。
|
||||
|
||||
备份与执行顺序(生产必须在明确批准后执行):
|
||||
|
||||
1. 备份当前 `main`/部署 SHA、现有视图 DDL/ACL/所有者及 `_prisma_migrations` 状态。生成目标库 `pg_dump -Fc` 备份并验证可读取;备份进入生产主机权限 0700 目录,文件 0600,不进入 Git 或共享日志。备份可能含密文,按敏感材料管理。
|
||||
2. 本次不修改任何密码、银行卡号或服务令牌;备份不得进入 Git 或普通日志。
|
||||
3. 执行明文状态修复迁移,视图短时获得 DDL 锁(最多等 5 秒,执行预算 30 秒);不回放未审查的其他迁移。
|
||||
4. 在同一只读快照比较迁移前后原字段行集、顺序/类型、行数/唯一性和权限;运行 `scripts/sql/check-store-account-password-reveal.sql`,确认最后字段 boolean,报告 true/false/null 聚合。预计当前数据为 957/1/0,但必须执行后实测。
|
||||
5. 按项目主线发布流程部署,完成逐跳 TLS、网关日志/限流/缓存检查后再配令牌。生产虚构账号的创建、调用与删除属于额外生产写入,需单独批准;不要调用真实账号作为测试。
|
||||
|
||||
回滚:先移除财务令牌阻止新调用并回退应用 SHA。新增状态列与审计表可安全保留以避免丢失审计;旧应用不依赖它们。需要完整结构回退时,经 DBA 检查依赖后,在事务中以备份 DDL 重建旧视图并恢复原所有者/ACL(PostgreSQL 不允许 `CREATE OR REPLACE VIEW` 删除尾列,禁止随意 `DROP ... CASCADE`)。审计记录保留,不直接删表;按项目规则处理已应用迁移历史,禁止直接删除 `_prisma_migrations` 记录或用 `db push`。任何完整结构回退先在隔离库演练。
|
||||
|
||||
## 密钥轮换
|
||||
|
||||
服务令牌:在受限凭据系统生成新随机令牌;旧值暂放 `STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS`,新值放主变量,重启上游;更新财务后端,使用虚构账号验收;移除旧值并重启。两值对应同一服务身份,审计/额度不变。怀疑泄露时立即撤销旧值,宁可短暂拒绝请求。
|
||||
|
||||
网关凭据:暂停此接口,在网关及上游同时更新 `STORE_ACCOUNT_REVEAL_EDGE_TOKEN` 后验收并恢复;不支持旧值并行。不得将网关凭据与服务令牌设成相同值。
|
||||
|
||||
历史兼容密钥:当前明文规则不需要为新值轮换数据密钥。若确认数据库中不存在 `v1:` 值并且备份保留策略允许,可另行评估退役历史解密密钥;退役前必须检查备份恢复场景,不能直接删除。
|
||||
|
||||
## 本次验证记录
|
||||
|
||||
- 当前页批量扩展的服务、HTTP 路由和代理边界共 3 个测试套件、41 项测试通过;`npm run type-check` 通过。测试覆盖 1–50 个账号、重复账号拒绝、部分 404/429/503、批次与每账号审计,以及失败时不返回明文。
|
||||
- 单元/HTTP/代理路由测试:34 项通过,含伪造 HTTPS URL/转发头和错误网关凭据拒绝。
|
||||
- 追加现有代理路由的 7 项回归,与 10 项集成测试合计 6 套件、51 项全部通过。
|
||||
- 隔离本地 PostgreSQL 集成测试:10 项通过,覆盖完整加解密与 HTTP handler、已提交审计、403/404/429/no-store、并发/跨实例限流、分钟/24小时配额、审计失败拒绝泄露、原字段及筛选保留、958/958 与 boolean 957/1/0、普通只读角色拒绝基表/密码/审计读取。
|
||||
- 开启本地测试连接的 PostgreSQL statement 日志,只在内存扫描 `docker logs`;应用日志、审计行、SQL请求与真实数据库日志均未检出随机测试密码。测试无需打印测试密码,断言只输出布尔结果。
|
||||
- Type-check 通过;范围内 ESLint 通过。全仓库主数据边界巡检有 6 项已有违规,均位于本次未修改模块;不得据此声称全仓库通过。
|
||||
- 2026-09-09 生产只读复核确认:958 行、958 个唯一 ID、957 条非空密码、1 条空密码;旧正则导致状态误报为全 false。本迁移用于恢复预期聚合 true/false/null = 957/1/0。
|
||||
|
||||
运行:`npx jest --runInBand --runTestsByPath src/server/services/store-account-password-reveal.service.test.ts src/proxy-store-account-reveal.test.ts "src/app/internal/store-accounts/[id]/password/reveal/route.test.ts" tests/integration/store-account-password-reveal.test.ts`。未配置本地测试库变量时,集成套件明确 skip,不能算真实数据库验收通过。
|
||||
|
||||
收口:**passed with warnings**。这是自审,不是独立复审。Route → Service → Repository 边界保持;未修改 UI;状态字段与当前明文存储规则对齐,专用认证、审计和限流边界不变。
|
||||
@ -1,101 +0,0 @@
|
||||
# 1688 Dropship Integration
|
||||
|
||||
## Scope
|
||||
|
||||
The module at `/supply-chain/new-products/1688-dropship-data` includes the DF
|
||||
product table, date and multi-select column filters, real-performance ordering,
|
||||
and daily/weekly/monthly trend details with prior-month comparisons.
|
||||
|
||||
It reads the shared product archive and daily sales fact table. The shared
|
||||
`barcode + statDate` key, unmatched source barcodes, synchronization lock,
|
||||
staging, and atomic publication are preserved. No second daily table or
|
||||
archive foreign key is introduced.
|
||||
|
||||
## Field Contract
|
||||
|
||||
| Display | Source / calculation |
|
||||
| --- | --- |
|
||||
| Sales amount | `sales_amount` -> daily `salesAmount` |
|
||||
| Real performance | `real_performance` |
|
||||
| After-sale amount | `after_sale_amount` |
|
||||
| After-sale cost rate | After-sale amount / real performance; blank for zero denominator |
|
||||
| Gross profit | Real performance - clear sales cost - send freight |
|
||||
| SKU operator | DF barcode/day distinct `ider`, latest nonempty value in selected period |
|
||||
| Total cost | Latest available daily `unitCost + estimatedFreight` in selected period; missing components are displayed as zero |
|
||||
| Unit cost | Latest available daily DF cost from `goods_count = 1` observations; the existing product cost excludes freight; missing values are displayed as zero |
|
||||
| Estimated freight | Average `send_freight` for the same DF barcode/day where `goods_count = 1`; the stored value remains null when no quantity-one observations exist and the page displays zero |
|
||||
|
||||
The archive's existing `shipAmount` continues to use `clear_goods_cost`.
|
||||
It must not be substituted for the dropship module's `salesAmount`.
|
||||
Cost ties use quantity-one frequency, total frequency, then descending unit cost. The page displays total cost before unit cost and estimated freight.
|
||||
|
||||
## Deployment Prerequisites
|
||||
|
||||
Code publication is not production deployment or database/data synchronization.
|
||||
No credentials, database dumps, local accounts, or local environment files are
|
||||
included in this change.
|
||||
|
||||
1. Confirm the target PostgreSQL identity, backup, and migration readiness using
|
||||
the repository deployment/database workflow. Apply migration
|
||||
`20260912120000_add_daily_sales_dropship_fields` after the existing mainline
|
||||
daily-sales migration. Do not replay the old local prototype's daily-table
|
||||
creation migrations against the mainline database.
|
||||
2. Generate the Prisma client and build the application with the target's
|
||||
existing environment configuration.
|
||||
3. Run the existing `npm run sync:supply-chain-product-archive-daily --` command
|
||||
with `--backfill`, `--apply`,
|
||||
`--confirm=sync-supply-chain-product-archive-sales-performance-daily`,
|
||||
`--expected-database-identity=...`, and
|
||||
`--expected-source-database-identity=...` using verified identities.
|
||||
The upstream requires read access to the configured
|
||||
`JST_REPORT_DB_SCHEMA.dws_sn_sales_full_info_di`; credentials remain in the
|
||||
target environment's `JST_REPORT_DB_*` variables.
|
||||
4. Check the completed sales sync run, historic DF `salesAmount` completeness,
|
||||
module view permission `newProduct1688DropshipData`, images, filters, totals,
|
||||
and trend comparisons. The existing daily schedule then maintains the data.
|
||||
|
||||
The migrations add `skuOperator` (empty by default), `unitCost` (nullable),
|
||||
`estimatedFreight` (nullable), and `salesAmount` (nullable). Historical facts need a backfill; the ordinary
|
||||
seven-day refresh does not populate all older dates. Until backfilled, unknown
|
||||
sales totals display as `--`, not zero or shipped amount.
|
||||
|
||||
Filtering uses the existing cache service with user-scoped keys and a 24-hour
|
||||
TTL. Multi-process deployments should provide the existing shared Redis cache.
|
||||
The module uses its own authenticated DF-only image route under
|
||||
`/api/supply-chain/1688-dropship-data/source-image`. Its view permission does not
|
||||
require granting access to the full product archive; the shared image service
|
||||
and its source/URL restrictions are retained.
|
||||
Rollback can restore the prior application version while retaining the additive
|
||||
columns; do not drop the daily table or discard its history.
|
||||
|
||||
## Validation And Review
|
||||
|
||||
Independent review conclusion: `passed with warnings`. Architecture and
|
||||
root-cause handling passed, including the corrected image-permission boundary.
|
||||
The existing visual implementation was retained; target-environment screenshots
|
||||
and real-data acceptance were not performed in this integration.
|
||||
|
||||
Final integration includes shared-mainline update `5156ead4`, retaining its
|
||||
separate dropship-issues entry and permissions. After merging, 20 focused Jest
|
||||
suites (171 tests), TypeScript checking, relevant ESLint, and the production
|
||||
build passed. The build retains two unrelated broad-file-pattern warnings in
|
||||
the existing OCR route and DWG wrapper.
|
||||
|
||||
The integration was checked with focused Jest suites for the page, trends,
|
||||
filters, permissions, image route, service/repository mapping, and shared daily
|
||||
sync; TypeScript checking; changed-file ESLint; and a production build using
|
||||
isolated placeholder configuration (no live database). An in-memory PostgreSQL
|
||||
compatibility replay confirmed that the additive migration retains existing
|
||||
DF/non-DF facts and the original primary key. Image-route tests exercise the
|
||||
real request/auth wrapper, including module-only access and denied requests.
|
||||
|
||||
The repository-wide `db:verify-master-data:code` and
|
||||
`db:verify-master-data:truth` audits still fail on existing baseline issues.
|
||||
Independent comparison with clean shared-mainline commit `38259cc4` found no
|
||||
new blocking findings: code-audit finding content is unchanged apart from
|
||||
schema line offsets, and the truth-audit JSON is identical. These global audit
|
||||
failures are not presented as passing checks.
|
||||
|
||||
No target deployment, live target database migration/backfill, or live-data
|
||||
browser validation of the integrated version is included in this publication.
|
||||
The existing local test application and its prototype database are unchanged.
|
||||
@ -1,54 +0,0 @@
|
||||
# 产品全量档案图片索引操作说明
|
||||
|
||||
全量档案本地图片索引只读取 `SupplyChainProductArchive.image`。上游「我司 SKU 命中」继续使用现有独立图搜服务。
|
||||
|
||||
## 更新规则
|
||||
|
||||
- 有图片且没有索引:建立索引。
|
||||
- 图片地址变化、旧索引失效、模型版本或向量规格变化:重新索引。
|
||||
- 图片为空或地址已经变化:旧向量退出本地搜索。任务会把旧记录标为 `STALE`。
|
||||
- 下载或计算失败:保存 `FAILED` 和原因,六小时后再次尝试;图片地址再次变化则立即处理。
|
||||
- 图片内容相同、地址不同:复用向量并更新地址与状态,避免反复处理同一张图片。
|
||||
- 下载、计算期间图片改变或产品删除:提交前锁定并核对档案行,丢弃过期结果,下一轮处理最新图片。
|
||||
- 所有索引任务使用同一 PostgreSQL 会话锁,避免手动补索引、同步触发与定时任务并发计算。
|
||||
- 正常增量任务不会重新下载全部已就绪图片;同一 URL 下替换了图片内容时,使用完整内容核验命令。
|
||||
|
||||
## 本地命令
|
||||
|
||||
在主应用目录执行,模型沿用现有 `IMAGE_SEARCH_MODEL_PATH` 或 `.local-services/models/dinov2-small`。
|
||||
|
||||
```bash
|
||||
# 每次最多处理 200 条缺失、换图或到期重试记录
|
||||
npm run image-search:index
|
||||
|
||||
# 补齐当前全部待处理记录,并立即重试历史失败
|
||||
npm run image-search:index -- --limit=0 --retry-now
|
||||
|
||||
# 下载所有带图档案,核验同一 URL 下的图片内容变化
|
||||
npm run image-search:index -- --all --limit=0
|
||||
```
|
||||
|
||||
命令持续输出已处理、成功、复用、失败、跳过、过期结果丢弃和失效条数;失败行输出条码与原因。最后输出本轮结果及待处理数量。失败仍会进入后续重试,本轮失败数必须单独关注,`remaining` 只计算已到重试时间的记录。
|
||||
|
||||
## 生产启用
|
||||
|
||||
代码必须先进入被授权的 `origin/main`,再经统一部署入口发布。安装定时器与首次补索引按用户授权执行,不能把本地开发完成等同于生产已启用。
|
||||
|
||||
安装入口:`scripts/install-product-archive-image-index-timer.sh`。它要求现有生产模型文件齐全,并让任务显式使用共享模型目录。当前应用启动命令已注入模型路径,无须为本功能重写应用环境。
|
||||
|
||||
- 服务:`furniture-product-archive-image-index.service`。
|
||||
- 定时器:`furniture-product-archive-image-index.timer`,每五分钟检查,单次最多处理 200 条,失败六小时后重试。
|
||||
- 两个现有全量同步服务分别增加本功能自己的 `image-index.conf`,同步成功后异步启动索引服务。
|
||||
- 前台保存、导入及其他来源的图片变化由五分钟定时检查覆盖;索引下载与推理不阻塞保存请求。
|
||||
- 通过 `PRODUCT_ARCHIVE_IMAGE_INDEX_LIMIT` 可在安装时调整单次处理上限。
|
||||
- 脚本和日志通过发布目录与 systemd 管理,不手工修改发布目录。
|
||||
|
||||
```bash
|
||||
bash scripts/install-product-archive-image-index-timer.sh
|
||||
systemctl status furniture-product-archive-image-index.timer --no-pager
|
||||
journalctl -u furniture-product-archive-image-index.service -n 50 --no-pager
|
||||
```
|
||||
|
||||
首次补齐历史缺口可等待定时任务分批完成;需要一次补完时,手动索引命令必须指向共享模型路径。运行中的另一索引任务会返回 `LOCKED`,稍后重试。
|
||||
|
||||
验收应核对有图档案与索引的关联,而不是只看 `READY` 行数:当前地址一致、状态 `READY`、模型版本与 768 维向量均符合要求才是可用索引。历史 404 或不可读取地址不能靠索引任务生成图片,需要按条码定位源图片问题。
|
||||
@ -34,10 +34,6 @@
|
||||
- 定时频率:每天 `03:00` 和 `15:00`。
|
||||
- 源库连接信息只允许放在生产本地 `/opt/furniture-3d-system-prod/shared/employee-directory-sync.env`,不能写入 Git 或共享文档。
|
||||
- 生产应用内部接口密钥继续从 `/opt/furniture-3d-system-prod/shared/.env` 的 `HR_SYNC_SECRET` 读取。
|
||||
- 人员信息表的钉钉组织同步使用 `furniture-3d-system/scripts/install-dingtalk-directory-sync-timer.sh`,生产 systemd 名称为 `furniture-dingtalk-directory-sync.service` / `furniture-dingtalk-directory-sync.timer`。
|
||||
- 钉钉同步每天 `03:00` 执行一次,读取生产共享 `.env` 中的 `DINGTALK_API_CLIENT_ID` 和 `DINGTALK_API_CLIENT_SECRET`,并自动更新人员信息;正式页面仍保持只读。钉钉网页登录使用独立的 `DINGTALK_LOGIN_CLIENT_ID`、`DINGTALK_LOGIN_CLIENT_SECRET`,两组凭据不能混用。
|
||||
- 人员信息的每日更新口径:钉钉新人自动新增一行;在职人员按钉钉更新部门/负责人;人工覆盖(manualOverride)的行保持不变;**离职人员不做删除,只在页面把「在职状态」变为离职**(方案 B:先比对上一轮在册且本轮抓取范围内消失的人,再逐个调 `user/get` 确认 60121 用户不存在才标记;抓取人数明显缩水时整轮跳过并写入告警)。
|
||||
- 首次部署后需在正式服务器执行该安装脚本,并手动执行一次 `npm run sync:dingtalk-directory:apply` 完成首次填充;后续由 systemd 每日执行。
|
||||
|
||||
## BOM 成本参考镜像
|
||||
|
||||
@ -54,38 +50,6 @@
|
||||
- 准确度回测:`npm run backtest:bom-cost-reference -- --sample-size=30`,只读最新成功镜像,不访问上游;样本限制 20 至 50 个,任一可比较 SKU 超过固定 10% 误差门槛即失败。
|
||||
- 迁移、真实同步、安装/启用定时器和生产回测都必须另行获得生产操作授权;部署代码本身不代表这些动作已经执行。
|
||||
|
||||
## 商品 SABC 月度核算
|
||||
|
||||
- 核算命令:`npm run run:product-sabc -- --year=2026 --month=8 --mode=APPLY --confirm=run-product-sabc`;试算(只算不写库)用 `--mode=PREVIEW`,末尾加 `--skip-if-exists` 可跳过已生成月份。
|
||||
- 8 月人工表对账命令:`npm run compare:product-sabc-xlsx`,默认读取本机下载的人工工作簿;生产执行前应先完成产品全量档案日销售数据同步。
|
||||
- 生产定时器安装脚本:`furniture-3d-system/scripts/install-product-sabc-timer.sh`。
|
||||
- 生产 systemd 名称:`furniture-product-sabc-monthly.service` / `furniture-product-sabc-monthly.timer`。
|
||||
- 默认调度:每月 3 日中国标准时间 `03:00`,附带最多 10 分钟随机延迟;服务生成上一个自然月的数据,已生成过则跳过(不覆盖管理员的重算结果)。
|
||||
- 生成即冻结:每次生成会把整行明细写入 `SupplyChainProductArchiveSabcResult.snapshot`,页面、筛选下拉、导出和经营总览都只读取该快照。生成之后档案的销售状态、人员字段等变化都不会再改变已生成月份的数据。
|
||||
- 重新生成(页面「执行核算」)仅限管理员(`ADMIN` / `SUPER_ADMIN`);其他账号只能查看冻结结果。本地一键刷新 `node scripts/local-sync-archive.mjs` 在每月 3 日前会跳过 SABC 步骤,需要立刻重算时用 `--force-sabc`。
|
||||
- SABC 的销售收款总金额、真实业绩、发货件数、商品毛利额和售后金额统一读取 `SupplyChainProductArchiveSalesPerformanceDaily`,与产品全量档案页面保持同一月度口径。
|
||||
- 首次安装生成权限为 `600` 的 `/opt/furniture-3d-system-prod/shared/product-sabc-timer.env`,默认 `PRODUCT_SABC_TIMER_MODE=APPLY`;如需先观察可临时改为 `PREVIEW`(只试算不写库、不冻结)。
|
||||
- SABC 自动任务只读取现有 `DATABASE_URL`;上游日销售同步任务继续使用 `JST_REPORT_DB_*`,凭据不写入定时器配置。安装、迁移、首次 APPLY 和生产核算都需要单独授权。
|
||||
|
||||
## 操作日志保留策略
|
||||
|
||||
- 清理脚本:`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` 和后续代码部署均需另行获得授权。
|
||||
|
||||
## 停产需求确认超时收口
|
||||
|
||||
- 收口脚本:`furniture-3d-system/scripts/finalize-production-stop-confirmations.ts`,把超过确认截止时间仍未反馈的停产需求按「默认同意停产」落库,并给未反馈的确认人记 `TIMEOUT` 票。
|
||||
- 生产安装入口:`furniture-3d-system/scripts/install-production-stop-confirmation-finalize-timer.sh`。
|
||||
- 生产 systemd 名称:`furniture-production-stop-confirmation-finalize.service` / `furniture-production-stop-confirmation-finalize.timer`,默认每 10 分钟检查一次(北京时间),附带最多 1 分钟随机延迟;可用 `PRODUCTION_STOP_FINALIZE_CALENDAR` 覆盖调度。
|
||||
- 收口逻辑幂等:只处理「已过截止时间且仍待确认」的需求,重复执行不会重复收口;失败由 systemd 在 5 分钟后自动重试。
|
||||
- 运行日志:`journalctl -u furniture-production-stop-confirmation-finalize -n 100 --no-pager`,每次运行输出本次收口的需求数量。
|
||||
- 首次安装与启用:在项目根执行 `bash scripts/install-production-stop-confirmation-finalize-timer.sh`(需 root,脚本写入 `/etc/systemd/system/` 并 `enable --now`);安装/启用定时器属于生产操作,需要另行获得授权。
|
||||
|
||||
## 明确排除
|
||||
|
||||
- `192.168.58.20` 不是当前 `furniture-3d-system` 的生产部署目标。
|
||||
|
||||
@ -1,163 +0,0 @@
|
||||
# 类目配置取数与筛选逻辑
|
||||
|
||||
> 用途:给需要接入类目配置数据的开发人员使用。本文描述当前代码和数据库的真实口径(2026-09-24 更新:合并为单表展示结构)。
|
||||
>
|
||||
> 重要:本文不包含数据库连接串、账号、密码。连接信息统一见《README.md》。
|
||||
|
||||
## 1. 数据源与责任边界
|
||||
|
||||
- **权威数据源**:本系统 `DATABASE_URL` 指向的 PostgreSQL,schema `public`,共 2 张表(见第 2 节)。数据通过类目配置页面人工维护 + Excel 导入,**没有外部同步来源**。
|
||||
- **业务作用**:
|
||||
1. 维护「二级类目 → 一级类目」的唯一归属(产品全量档案类目列的规范值来源);
|
||||
2. 维护每个类目的 4 档规模阈值(爆款/热卖款/动销款/低动销款 × 业绩区间、日销区间)与「分类」归属。
|
||||
- **人工维护原则**:直接写表会绕过唯一键校验和页面权限,接入方一律只读。
|
||||
|
||||
### 1.1 代码依据
|
||||
|
||||
- Prisma 模型:`prisma/schema.prisma` 中 `SupplyChainProductArchiveCategoryConfig`、`SupplyChainProductArchiveCategoryScaleRow`。
|
||||
- 页面:`src/app/supply-chain/database/category-config/`(单表展示,数据源即规模行表)。
|
||||
- 服务:`supply-chain-product-archive-category-scale-row.service.ts`(页面主数据源,含类目清单同步)、`supply-chain-product-archive-category-config.service.ts`(类目主表维护)。
|
||||
|
||||
## 2. 物理表清单与分工
|
||||
|
||||
| 表名 | 角色 | 当前行数量级 |
|
||||
| --- | --- | --- |
|
||||
| `"SupplyChainProductArchiveCategoryScaleRow"` | **类目规模行(页面当前数据源)**:一行 = 二级类目 × 规模标签,自带一级类目、分类、阈值 | 类目数 × 4 |
|
||||
| `"SupplyChainProductArchiveCategoryConfig"` | **类目主表**:一行一个二级类目的身份记录(唯一性、启停状态),供产品档案同步、类目下拉等消费方使用 | 80+ |
|
||||
|
||||
两表关系:
|
||||
|
||||
```text
|
||||
ScaleRow(页面表:一级/二级/分类/阈值/标签,行=类目×档位,唯一键 categoryL2+scaleLabel)
|
||||
│ 服务写入时自动同步(新增/改行会把一级类目、分类落到主表)
|
||||
▼
|
||||
Config(类目身份表:categoryL2 全表唯一 + status 启停,供外部消费方取类目清单)
|
||||
```
|
||||
|
||||
> 历史说明:旧三表结构中的「分类档位模板表」(`SupplyChainProductArchiveCategoryGroupTier`)已于 2026-09-24 迁移(20260924160000)删除,其数据已按类目展开并入规模行表;旧接口 `/api/supply-chain/category-config/group-tiers/*` 已下线。
|
||||
|
||||
## 3. 表字段字典
|
||||
|
||||
### 3.1 `"SupplyChainProductArchiveCategoryScaleRow"`(类目规模行,页面数据源)
|
||||
|
||||
| 字段 | 类型/约束 | 业务含义 | 取值规则 |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `TEXT` 主键 | 行 ID | 系统生成 |
|
||||
| `categoryL1` | `TEXT NOT NULL DEFAULT ''` | 一级类目(≤160 字符) | 页面/导入必填 |
|
||||
| `categoryL2` | `TEXT NOT NULL` | 二级类目名(≤160 字符) | 与 `scaleLabel` 联合唯一 |
|
||||
| `categoryGroup` | `TEXT NOT NULL DEFAULT ''` | 分类(如 电脑桌/书桌、其他沙发类) | 空 = 未分类 |
|
||||
| `scaleLabel` | `TEXT NOT NULL` | 规模标签 | 只能是 爆款/热卖款/动销款/低动销款 |
|
||||
| `performanceStart` | `DECIMAL(14,2)` NOT NULL | 业绩起点(元) | |
|
||||
| `performanceEnd` | `DECIMAL(14,2)` 可空 | 业绩终点(元) | **NULL = 不设上限** |
|
||||
| `dailySalesStart` | `DECIMAL(12,2)` NOT NULL | 日销起点(件/天) | 支持小数 |
|
||||
| `dailySalesEnd` | `DECIMAL(12,2)` 可空 | 日销终点(件/天) | NULL = 不设上限 |
|
||||
| `sortOrder` | `INTEGER` | 档位顺序:爆款1 → 热卖款2 → 动销款3 → 低动销款4 | 判定从高档往低档取第一个命中 |
|
||||
| `createdAt` / `updatedAt` | `TIMESTAMP(3)` | 系统字段 | |
|
||||
|
||||
约束:`UNIQUE(categoryL2, scaleLabel)`——一个类目每个标签只有一行;索引:`categoryL2`、`categoryGroup`。
|
||||
|
||||
### 3.2 `"SupplyChainProductArchiveCategoryConfig"`(类目主表)
|
||||
|
||||
| 字段 | 类型/约束 | 业务含义 | 取值规则 |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `TEXT` 主键 | 配置记录 ID | 系统生成 |
|
||||
| `categoryL2` | `TEXT` 唯一 | 二级类目名(≤160 字符) | 全表唯一 |
|
||||
| `categoryL1` | `TEXT NOT NULL` | 一级类目(≤160 字符) | |
|
||||
| `categoryGroup` | `TEXT NOT NULL DEFAULT ''` | 分类 | 空 = 未分类 |
|
||||
| `scaleLabel` | `TEXT NOT NULL DEFAULT ''` | 【预留未使用】 | 阈值数据在 ScaleRow 表 |
|
||||
| `performanceStart` / `performanceEnd` | `DECIMAL(14,2)` 可空 | 【预留未使用】 | 同上 |
|
||||
| `dailySalesStart` / `dailySalesEnd` | `DECIMAL(12,2)` 可空 | 【预留未使用】 | 同上 |
|
||||
| `status` | `TEXT`,`ACTIVE` / `INACTIVE` | 启用/停用;停用的不进类目选项 | 默认 `ACTIVE` |
|
||||
| `createdAt` / `updatedAt` | `TIMESTAMP(3)` | 系统字段 | |
|
||||
|
||||
索引:`categoryL1`、`categoryGroup`、`status`;`categoryL2` 唯一。
|
||||
|
||||
## 4. 其他线程取数方式
|
||||
|
||||
### 4.1 只读 SQL(直连数据库)
|
||||
|
||||
```sql
|
||||
-- 4.1.1 页面同款数据:全部规模行(一级类目、二级类目、分类、4 档阈值)
|
||||
SELECT "categoryL1", "categoryL2", "categoryGroup", "scaleLabel",
|
||||
"performanceStart", "performanceEnd", "dailySalesStart", "dailySalesEnd"
|
||||
FROM "SupplyChainProductArchiveCategoryScaleRow"
|
||||
ORDER BY "categoryL2" ASC, "sortOrder" ASC;
|
||||
|
||||
-- 4.1.2 某个类目的 4 档阈值
|
||||
SELECT "scaleLabel", "performanceStart", "performanceEnd", "dailySalesStart", "dailySalesEnd"
|
||||
FROM "SupplyChainProductArchiveCategoryScaleRow"
|
||||
WHERE "categoryL2" = '住宅家具 - 书桌'
|
||||
ORDER BY "sortOrder" ASC;
|
||||
|
||||
-- 4.1.3 类目身份清单(供下拉/同步用,只取启用的)
|
||||
SELECT "categoryL1", "categoryL2", "categoryGroup"
|
||||
FROM "SupplyChainProductArchiveCategoryConfig"
|
||||
WHERE "status" = 'ACTIVE'
|
||||
ORDER BY "categoryL1" ASC, "categoryL2" ASC;
|
||||
|
||||
-- 4.1.4 某分类下的全部类目及其阈值
|
||||
SELECT "categoryL2", "scaleLabel", "performanceStart", "performanceEnd"
|
||||
FROM "SupplyChainProductArchiveCategoryScaleRow"
|
||||
WHERE "categoryGroup" = '电脑桌/书桌'
|
||||
ORDER BY "categoryL2" ASC, "sortOrder" ASC;
|
||||
```
|
||||
|
||||
注意事项:
|
||||
|
||||
- 表名/字段名**必须带双引号**保持大小写。
|
||||
- 终点列为 `NULL` 表示不设上限,不要写成 0。
|
||||
- 停用(`INACTIVE`)类目历史仍在 Config 表里,统计按 `status` 过滤。
|
||||
- 商品/档案行上的类目与分类是写入时的快照,改配置不回刷历史数据。
|
||||
|
||||
### 4.2 代码内取数(同仓库服务)
|
||||
|
||||
```ts
|
||||
// 规模行(页面同款数据:一行=类目×档位,含一级类目与分类)
|
||||
import { supplyChainProductArchiveCategoryScaleRowService } from
|
||||
'@/server/services/supply-chain-product-archive-category-scale-row.service'
|
||||
await supplyChainProductArchiveCategoryScaleRowService.list({ page: 1, pageSize: 100 })
|
||||
await supplyChainProductArchiveCategoryScaleRowService.listAll({ categoryGroup: '电脑桌/书桌' })
|
||||
|
||||
// 类目身份清单(产品全量档案同步、类目下拉在用)
|
||||
import { supplyChainProductArchiveCategoryConfigService } from
|
||||
'@/server/services/supply-chain-product-archive-category-config.service'
|
||||
await supplyChainProductArchiveCategoryConfigService.list({ page: 1, pageSize: 100, status: 'ACTIVE' })
|
||||
|
||||
import { supplyChainProductArchiveCategoryConfigRepository } from
|
||||
'@/server/repositories/supply-chain-product-archive-category-config.repository'
|
||||
await supplyChainProductArchiveCategoryConfigRepository.findActiveByCategoryL2('住宅家具 - 书桌')
|
||||
```
|
||||
|
||||
### 4.3 走 API(需要登录 + 供应链权限)
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `/api/supply-chain/category-config/scale-rows` | GET / POST | 规模行列表 / 新增(写入时自动同步类目主表) |
|
||||
| `/api/supply-chain/category-config/scale-rows/{id}` | PATCH / DELETE | 规模行编辑 / 删除 |
|
||||
| `/api/supply-chain/category-config/scale-rows/export` · `/import` | GET / POST | 导出(筛选跟随当前搜索/分类/标签) / 流式导入(表头:一级类目、二级类目、分类、规模标签、业绩起终点、日销起终点) |
|
||||
| `/api/supply-chain/category-config/scale-rows/groups` | GET | 去重分类列表(筛选项) |
|
||||
| `/api/supply-chain/category-config` · `/{id}` | GET/POST/PATCH | 类目身份维护(供应商新品推荐下拉也走这里) |
|
||||
| `/api/supply-chain/category-config/export` · `/import` | GET / POST | 类目身份导出 / 导入 |
|
||||
|
||||
权限:查询走类目节点「查看」,导出走「下载」,写入走「编辑」;角色 `SUPER_ADMIN/ADMIN/SUPERVISOR` 或业务角色 `MANAGEMENT`。
|
||||
|
||||
## 5. 现有消费方(改动前必读)
|
||||
|
||||
| 消费方 | 取数点 | 依赖 |
|
||||
| --- | --- | --- |
|
||||
| 产品全量档案(DF/聚水潭同步) | `jst-sku-product-archive-sync.service.ts`、`jst-sku-product-archive-full-sync.service.ts` | `findActiveByCategoryL2`:仅启用类目作为规范值 |
|
||||
| 供应商新品推荐类目下拉 | `/api/supply-chain/supplier-recommended-products/category-options` | Config `service.list`,仅 `ACTIVE` |
|
||||
| 类目配置页面 | 第 4.3 节接口 | — |
|
||||
|
||||
改 `status`、`categoryL2` 名称、删除类目前先确认不影响以上消费方。
|
||||
|
||||
## 6. 接入前检查清单
|
||||
|
||||
- [ ] 表名/字段名带双引号保持大小写。
|
||||
- [ ] 方向:`categoryL2`(二级)→ `categoryL1`(一级);`categoryGroup`(分类)是独立维度,不是一级类目,空 = 未分类。
|
||||
- [ ] 终点列 `NULL` = 不设上限,不要当 0 处理。
|
||||
- [ ] 档位判定顺序:爆款 → 热卖款 → 动销款 → 低动销款,从高到低取第一个命中区间。
|
||||
- [ ] 阈值数据取 `ScaleRow` 表;Config 表上的 `scaleLabel`/阈值列为预留未使用。
|
||||
- [ ] 停用(`INACTIVE`)配置历史仍在 Config 表里,统计按 `status` 过滤。
|
||||
- [ ] 商品/档案行上的类目、分类是写入时快照,改配置不回刷历史数据。
|
||||
- [ ] 页面写操作会双写两表(规模行 + 类目主表同步),外部只读不写就不会产生不一致。
|
||||
@ -1,99 +0,0 @@
|
||||
# 内容视频流程看板取数与筛选逻辑
|
||||
|
||||
> 用途:给需要接入「新品-视频进度(内容部)」(钉钉内容视频流程)数据的开发人员使用。本文描述当前代码和数据库的真实口径(2026-10-08 首版)。
|
||||
>
|
||||
> 重要:本文不包含数据库连接串、账号、密码。该页面数据**不存在本系统 PostgreSQL**,全部来自企划 MySQL 的同步快照。
|
||||
|
||||
## 1. 数据源与责任边界
|
||||
|
||||
- **页面直接数据源**:企划 MySQL(连接变量 `JST_PLANNING_DB_*`,生产为生产服务器本机实例)的 `planning_database` 库,快照表 `oa_video_flow_board_snapshot`。
|
||||
- **原始数据源**:钉钉 OA 审批镜像库的 `ding_Workflow` / `ding_WorkflowScheme` / `ding_WorkflowTask` 三张表,按流程编码过滤:
|
||||
- 内容视频流程 `PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673`(登记在 `oa-board.constants.ts` 的 `OA_BOARD_PROCESS_CODES.video`)。
|
||||
- **同步方式**:`scripts/sync-oa-video-flow-board.ts --apply`(即 `npm run sync:oa-video-flow:apply`,服务 `oa-video-flow-board.service.ts`)全量拉取该流程全部实例与表单字段,按"一条流程实例 = 一行"写入快照表,并用 `sync_version` 清理已消失的实例。生产由 systemd 定时器(`install-oa-board-sync-timer.sh` 安装的 `furniture-oa-board-sync.timer`)每 15 分钟在主看板、品牌策划看板之后顺序执行。
|
||||
- **责任边界**:镜像库与钉钉侧的数据质量不由本系统负责;本系统保证"镜像库 → 快照 → 页面"这一段。下游一律**只读快照表**,不要直连 ding_ 三张源表(源表结构随钉钉变化,无稳定性承诺)。
|
||||
- **与新品主链的关系**:内容视频流程**不参与** OA 新品看板主链的项目聚合、总状态与时效矩阵(其表单无开款编号/关联单号,仅有 SPU)。本表是独立流程维度的进度数据。
|
||||
|
||||
### 1.1 代码依据
|
||||
|
||||
- 同步与快照读写的唯一实现:`src/server/services/oa-video-flow-board.service.ts`(建表 DDL 在 `ensureSnapshotTable`,自动建表)。
|
||||
- 源库连接复用:`oa-board-sync.service.ts` 导出的 `openOaBoardSourceMysqlConnection` / `openOaBoardSourceSqlServer`(跟随 `OA_BOARD_SOURCE_DRIVER`)。
|
||||
- 页面接口:`GET /api/intake/oa-board/video-flow`(参数 `keyword` / `demandType` / `brand` / `status` / `page` / `pageSize`,需登录态)。
|
||||
- 页面组件:`src/app/intake/oa-board/OaBoardVideoFlowPanel.tsx`(Tab 参数 `tab=video-flow`)。
|
||||
|
||||
## 2. 物理表(MySQL · planning_database)
|
||||
|
||||
| 物理表 | 用途 | 键与索引 |
|
||||
| --- | --- | --- |
|
||||
| `oa_video_flow_board_snapshot` | 最新快照,一行 = 一条内容视频流程实例 | `row_key` 唯一;`spu` / `status` / `stage_label` / `demand_type` / `brand` / `created_date` 有索引 |
|
||||
|
||||
同步任务自动建表(`CREATE TABLE IF NOT EXISTS`),引擎 InnoDB,字符集 utf8mb4。
|
||||
|
||||
## 3. 字段字典
|
||||
|
||||
| 字段 | 类型 | 含义 | 取值规则 |
|
||||
| --- | --- | --- | --- |
|
||||
| `row_key` | `VARCHAR(191)` 唯一 | 钉钉流程实例 ID(processInstanceId),幂等拉取键 | 源表主键,稳定不变 |
|
||||
| `business_id` | `VARCHAR(128)` | 流程发起编号 | 形如 `202610071758000088167` |
|
||||
| `title` | `VARCHAR(255)` | 流程标题 | `XX提交的内容视频和图片申请流程` |
|
||||
| `submitter_name` | `VARCHAR(255)` | 提交人姓名 | 从标题提取;`originator_user_id` 存钉钉用户 ID 兜底 |
|
||||
| `status` | `VARCHAR(32)` | 流程状态 | `RUNNING` / `COMPLETED` / `TERMINATED` |
|
||||
| `demand_department` | `VARCHAR(255)` | 需求部门 | 表单字段「需求部门」 |
|
||||
| `demand_type` | `VARCHAR(255)` | 需求类型 | 如 淘系-逛逛视频 / 抖音-千川素材视频 / 安装视频 / AI视频 等 13 种 |
|
||||
| `task_type` | `VARCHAR(255)` | 任务类型 | 如 布景拍摄 / 二次剪辑 |
|
||||
| `install_video_category` | `VARCHAR(255)` | 安装视频分类 | 仅安装视频类需求有值(如 B档) |
|
||||
| `brand` | `VARCHAR(255)` | 品牌 | 表单字段「品牌」 |
|
||||
| `scene_category` | `VARCHAR(255)` | 场景类别 | 如 卧室 / 客厅 / 玄关 |
|
||||
| `spu` | `VARCHAR(64)` | SPU | 大写规范化;约 3% 行无值(账号维护等非产品需求) |
|
||||
| `product_id` | `VARCHAR(128)` | 商品ID | 表单字段「商品ID」,可能为"暂无" |
|
||||
| `sample_barcodes` | `TEXT` | 样品条码 | 表单「样品明细」表格中商品编码列,多值分号分隔 |
|
||||
| `content_owner` | `VARCHAR(255)` | 内容部负责人 | 表单字段 |
|
||||
| `planning_supervisor` | `VARCHAR(255)` | 品牌策划主管 | 表单字段 |
|
||||
| `script_director` | `VARCHAR(255)` | 编导 | 流程推进到该环节时才填,未填为 NULL |
|
||||
| `cameraman` | `VARCHAR(255)` | 摄像 | 同上 |
|
||||
| `editor` | `VARCHAR(255)` | 剪辑 | 同上 |
|
||||
| `retoucher` | `VARCHAR(255)` | 修图 | 可选环节 |
|
||||
| `cover` | `VARCHAR(255)` | 封面 | 同上 |
|
||||
| `stage_label` | `VARCHAR(64)` | 当前阶段 | 推断规则见第 4 节 |
|
||||
| `acceptance_result` | `VARCHAR(255)` | 验收结果 | 表单「运营/策划/客服验收结果」 |
|
||||
| `score` | `VARCHAR(64)` | 评分 | 表单「评分」 |
|
||||
| `created_date` | `DATETIME` | 流程发起时间(北京时间) | 源表 createAt |
|
||||
| `finished_date` | `DATETIME` | 流程完成时间 | RUNNING 为 NULL |
|
||||
| `duration_days` | `DECIMAL(10,2)` | 总时效(天) | 发起 → 完成;未完成计至同步时刻 |
|
||||
| `last_source_updated_at` | `DATETIME` | 源表最近更新时间 | 增量拉取判据 |
|
||||
| `sync_version` | `VARCHAR(64)` | 本次同步批次标识 | 全表同批;用于清理已消失实例 |
|
||||
| `synced_at` | `TIMESTAMP` | 本行最近同步时间 | 数据新鲜度判据(`MAX(synced_at)`) |
|
||||
|
||||
## 4. 阶段(stage_label)推断规则
|
||||
|
||||
源表只有 activityId 没有节点名,首版按表单字段的推进顺序推断(钉钉流程设计中角色字段按环节顺序逐步填写):
|
||||
|
||||
```text
|
||||
status = COMPLETED → 已完结
|
||||
status = TERMINATED → 已终止
|
||||
验收结果或评分已填 → 验收/评分
|
||||
否则取已填角色的最后一个:
|
||||
编导 → 编导制作 → 摄像 → 摄影拍摄 → 剪辑 → 剪辑制作
|
||||
→ 修图/封面 → 封面/修图制作
|
||||
一个角色都没填 → 需求发起
|
||||
```
|
||||
|
||||
若后续拿到钉钉流程节点名清单,可升级为 activityId 级精确映射(预留位:`oa-board.constants.ts` 的 `OA_BOARD_ACTIVITY_MAPPING_SEED.video` 当前为空数组)。
|
||||
|
||||
## 5. 接入示例(一律只读)
|
||||
|
||||
```sql
|
||||
-- 按 SPU 拉该产品的全部视频流程
|
||||
SELECT * FROM oa_video_flow_board_snapshot WHERE spu = 'SJ0159';
|
||||
|
||||
-- 只看进行中的
|
||||
SELECT * FROM oa_video_flow_board_snapshot WHERE status = 'RUNNING';
|
||||
|
||||
-- 增量拉取(幂等键 row_key)
|
||||
SELECT * FROM oa_video_flow_board_snapshot
|
||||
WHERE last_source_updated_at > :上次拉取时间;
|
||||
|
||||
-- 取数前先看新鲜度(生产最多滞后 15 分钟)
|
||||
SELECT MAX(synced_at) FROM oa_video_flow_board_snapshot;
|
||||
```
|
||||
|
||||
关联口径:与产品数据对齐用 `spu` 或 `sample_barcodes` 中的条码;本表**没有开款编号**,不要与主看板的 `OPENING:` 维度直接关联。
|
||||
@ -1,188 +0,0 @@
|
||||
# BOM 成本预估 v2(agent 方案)设计文档
|
||||
|
||||
> 日期:2026-09-16
|
||||
> 状态:设计稿,待评审
|
||||
> 前置阅读:`docs/项目总览.md`、`src/server/services/bom-costing/agent/`(已建成的引擎)
|
||||
> 关联会话结论:1688 以图搜图锚定为过渡方案(双重失败已实测证伪),终局为 agent 估算 + 公司数据
|
||||
|
||||
## 1. 一句话定位
|
||||
|
||||
**粘贴竞品链接 → 几分钟后得到一张诚实的成本预估卡。**
|
||||
|
||||
原始需求从未变过。v2 的一切设计决策都回到这句话:数字 + 构成故事 + 诚实的区间,工序全部退到幕后。
|
||||
|
||||
## 2. v1 的教训(为什么 v2 长这样)
|
||||
|
||||
| v1 的问题 | 根因 | v2 的对策 |
|
||||
|---|---|---|
|
||||
| 工序暴露给用户(逐图泳道、五阶段时间线、重算模式、复核流) | 工程美德当成了产品 | 一根进度条 + 一张卡;审计链存在但默认隐藏 |
|
||||
| 27 个 SKU 全量核算(用户等十几分钟才见结果) | 首结果时间没有被设计 | 首批 3 个精算先出结果,其余按需点算,结果缓存 |
|
||||
| 十几分钟等待 | 按 SKU 串行/并发全量跑 | case 级一次运行,2-3 分钟 |
|
||||
| 复杂度随精度承诺膨胀 | 用流程堆精度 | 用数据+校准逼近精度(±10% 是"尽量"的校准目标) |
|
||||
| 1688 锚定顶替估算 | 上线压力下的权宜 | 1688 永远是后台交叉验证信号 |
|
||||
|
||||
**必须保留的教训**:用户会锚定数字。区间 + 置信度 + 不确定点是答案的一部分,不是脚注——这是"给一个数"和"给一个诚实的数"的差别。
|
||||
|
||||
## 3. 设计总纲(五条,裁决一切分歧)
|
||||
|
||||
1. **预估,不是核算**:输出是带区间与置信度的假设生成器,供决策筛选,永不承诺报价级精度。
|
||||
2. **agent 主导分析,代码承载确定性**:看什么图、怎么拆结构、查什么价格由 agent 决定;一切数值计算(汇总、区间合成、损耗)由确定性代码完成。模型永不算数、永不编价。
|
||||
3. **价格只认公司数据**:历史 BOM 镜像是唯一定价依据(检索→分类中位→cohort→损耗四级回退);1688 行情仅作后台交叉验证,永不成为展示价。
|
||||
4. **前端来源中性(不可触碰)**:界面永不出现 1688 字样/报价/链接;一切序列化出口经过 `src/lib/bom-costing/public-estimate-text.ts`。
|
||||
5. **实测闸门**:agent 流程未经回测与真实案例对比验证(偏差分布达标)前,不替换任何线上行为。
|
||||
|
||||
## 4. 用户形态
|
||||
|
||||
### 4.1 默认入口:预估卡 + 全量 SKU 按需核算
|
||||
|
||||
**覆盖率是需求,等待时间是体验问题**——所有 SKU 都要能出结果,但采用"先出一批、其余按需点算"的渐进交付:
|
||||
|
||||
```
|
||||
粘贴链接 → [进度条:抓取中 → 分析中 → 出预估](2-3 分钟)→
|
||||
|
||||
┌────────────────────────────────────────┐
|
||||
│ 源氏木语 白蜡木实木衣柜 │
|
||||
│ 预计出厂成本 ¥260 – 380(首批 3 个 SKU)│
|
||||
│ 置信度:中等 │
|
||||
│ 板材 ¥180± · 五金 ¥25± · 加工+人工 ¥60± · 包材 ¥15± │
|
||||
│ 依据:8 张商品图 + 公司历史 BOM 12 条 │
|
||||
│ 不确定:板厚未确认;铰链数量按两门推定 │
|
||||
└────────────────────────────────────────┘
|
||||
SKU 列表(27 个):
|
||||
[¥260.29 已核算] (3) 原木色 0.9米两门 ← 首批自动精算
|
||||
[¥375.48 已核算] (7) 原木色 1.0米 ← 首批自动精算
|
||||
[¥406.77 已核算] (18) 原木色 1.96米 ← 首批自动精算
|
||||
[点击核算] (4) 原木色 0.9米/高2.2米 ← 用户点击后单独运行
|
||||
[点击核算] ... ← 已算结果缓存,不重跑
|
||||
────────────────────────────────────────
|
||||
商品图片(保留现有形式:主图/SKU图/详情图 分类浏览 + 大图预览)
|
||||
```
|
||||
|
||||
- **首批自动精算**:默认 3 个代表性 SKU(跨度原则:最小/最大/中间,或按材质/结构差异选),2-3 分钟出卡——用户立刻有结果。
|
||||
- **其余 SKU 按需点算**:未核算的 SKU 显示占位按钮,用户点击触发单 SKU agent 运行(复用现有 per-SKU runner,1-2 分钟/个);**结果持久缓存**,重复点击不重跑。
|
||||
- 进度状态只有三个:抓取中 / 分析中 / 出预估(含失败态与重试按钮)。
|
||||
- **渐进展开**:点构成 → BOM 行明细;点行 → 证据(哪张图、哪条镜像参考行)。审计链存在,默认折叠。
|
||||
- **商品图片保留现有展示形式**(主图/SKU 图/详情图分类 + 逐图状态 + 大图预览):用户需要直接查看竞品链接内容,图片是判断依据,不是工程噪声。
|
||||
|
||||
### 4.2 深度模式(保留现有工作台)
|
||||
|
||||
需要全量 27 SKU 拆解、人工核对、证据溯源的用户,从预估卡一键进入现有工作台。v2 验证前工作台不动;验证后它降级为二级入口,不再是默认。
|
||||
|
||||
## 5. 架构:case 级 agent 运行
|
||||
|
||||
### 5.1 运行编排(新,核心增量)
|
||||
|
||||
交付模型:**首批自动精算 + 其余按需点算**。现有 per-SKU runner 是按需点算的执行体,case 级编排负责首批选择与结果汇总。
|
||||
|
||||
```
|
||||
阶段一:runBomCostingAgentEstimateForCase(caseId)(首批)
|
||||
1. 取案例简报(标题/渠道/已解析品类 skill)+ 归档素材 + SKU 清单(含尺寸)
|
||||
2. 代码侧选首批 SKU(默认 3 个:最小/最大/中间,或按材质/结构差异;
|
||||
单 SKU 商品即为它自己)
|
||||
3. 逐个运行现有 per-SKU agent 循环(结构行 + 查价)
|
||||
4. 代码侧汇总出 EstimateCard:卡面五行/区间/置信度 + SKU 列表状态
|
||||
(已核算 3 / 待核算 24)
|
||||
|
||||
阶段二:按需点算(用户驱动)
|
||||
用户点击未核算 SKU → 单 SKU agent 运行(现有 runner)→
|
||||
结果持久缓存 → 卡面/SKU 列表实时更新
|
||||
```
|
||||
|
||||
- 首批预算:≤24 轮 / ≤32 次模型调用 / ≤8 分钟(per-SKU 预算 × 3 + 编排开销)。
|
||||
- **结果缓存**:per-SKU 结果按 (caseId, skuId, 引擎版本) 持久化;点击已核算 SKU 直接展示缓存,不重跑。SKU 重算 = 显式刷新动作(换引擎版本或用户主动重跑)。
|
||||
- 失败语义:首批中单个 SKU 失败 → 该 SKU 标记失败可单独重试,不拖垮整卡;结构成功但价格饥饿 → 出结果但区间放宽 + 不确定点说明(PARTIAL 是合法产物)。
|
||||
- 并发上限与幂等:同一 SKU 的点算请求可去重合并(进行中重复点击返回同一运行)。
|
||||
|
||||
### 5.2 输出契约:EstimateCard(case 级)
|
||||
|
||||
```ts
|
||||
interface BomEstimateCard {
|
||||
caseId: string
|
||||
title: string
|
||||
skus: Array<{ // 全量 SKU 清单 + 状态
|
||||
externalSkuId: string
|
||||
name: string
|
||||
status: 'ESTIMATED' | 'PENDING' | 'FAILED' | 'RUNNING'
|
||||
estimate?: BomCostingAgentEstimate // 已核算时:行/区间/置信度/evidence
|
||||
cachedAt?: string
|
||||
}>
|
||||
firstBatchSkuIds: string[] // 首批自动精算的 SKU
|
||||
breakdown: Array<{ // 卡面五行(由已核算 SKU 的行汇总;随点算增长)
|
||||
category: 'BOARD'|'STEEL'|'ACCESSORY'|'PACKAGING'|'PROCESSING'|'LABOR'
|
||||
total: number | null // null = 该组无价,卡面显示"待补"
|
||||
}>
|
||||
totalRange: { min: number; max: number } // 当前已核算 SKU 的区间包络
|
||||
confidence: number // 已核算 SKU 的最小置信度
|
||||
openQuestions: string[] // 卡面"不确定"(已核算 SKU 的并集)
|
||||
usage: { rounds; modelCalls; elapsedMs }
|
||||
}
|
||||
```
|
||||
|
||||
原则:明细(行级)保留在各 SKU 的 estimate 里供展开;卡面只消费 breakdown/totalRange/confidence/openQuestions,并随按需点算增量更新。
|
||||
|
||||
### 5.3 价格供给(核心依赖,最大缺口)
|
||||
|
||||
首测"定价饥饿"的根治项——把 v1 场景服务的回退链包装成 agent 工具:
|
||||
|
||||
| 优先级 | 工具 | 包装自 |
|
||||
|---|---|---|
|
||||
| P0 | 尺寸感知镜像检索(分类/材质词/尺寸/参考 SKU 多级过滤) | `findReferenceCandidates` |
|
||||
| P0 | 分类画像中位价 | `fillCategoryProfileFallbacks` |
|
||||
| P1 | 同类 cohort 报价 | `resolveCohortQuote` |
|
||||
| P1 | 历史损耗率 | `applyHistoricalLossFactorFallbacks` |
|
||||
| P2 | AI 补估(置信度上限 0.75 + 标记复核) | 现有 estimator |
|
||||
|
||||
工具输出纪律:摘要级(无供应商/店铺/链接),带 sourceRowId 供 evidence 引用,单位明确。**1688 工具维持现状**(只透出状态/参考价/区间/可比数四个中性字段)。
|
||||
|
||||
### 5.4 数据与知识资产接入(全部保留,形态不变)
|
||||
|
||||
| 资产 | v2 中的角色 | 接入形态 |
|
||||
|---|---|---|
|
||||
| 历史 BOM 镜像(每日 MSSQL 同步) | 唯一价格依据 | 工具(5.3) |
|
||||
| 品类 Skill(5 个)+ 结构先验 | agent 脑中的专家清单:必备组件、证据纪律、校验规则 | 提示词注入(`renderBomCostingAgentSkillKnowledge`,已建成) |
|
||||
| 材质目录 | 材质身份唯一权威 | 工具(已接线) |
|
||||
| 商品抓取/归档/抠图 | 看图取证的素材 | 工具 + mission 组装(已接线;抠图模型待切 remove-background) |
|
||||
| 1688 行情 | 后台交叉验证信号 | 工具(已接线,中性摘要) |
|
||||
|
||||
### 5.5 校准闭环(±10% "尽量"的实现方式)
|
||||
|
||||
- **回测**:历史 BOM 镜像抽 20-50 个真实 SKU,同一规则/agent 重算 vs 真实成本,产出误差分布(复用 `backtest:bom-cost-reference` 基建)。
|
||||
- **shadow 对比**:agent 结果与现行引擎结果对同一案例并排落库(工程域),持续记录偏差(`report:bom-cost-shadow-divergence` 同款模式)。
|
||||
- **阈值语义**:±10% 是回测循环追的校准目标(按品类分段看分布),不是单案例门禁。偏差大 → 调工具/知识/提示词 → 再回测。
|
||||
- 未过门槛 → agent 流程保持旁路,线上仍是现行方案。
|
||||
|
||||
## 6. 前端形态
|
||||
|
||||
- **新入口**:`/bom-costing` 默认视图替换为预估卡流(粘贴框 → 卡 → SKU 列表 → 图片浏览)。
|
||||
- **商品图片**:保留现有展示形式(主图/SKU 图/详情图分类 tab + 大图预览 + 逐图状态),用户需要直接查看竞品内容;它从"处理进度件"回归"内容主体"。
|
||||
- **SKU 列表**:已核算显示价格,未核算显示"点击核算"按钮(运行中显示进度态);点击结果写缓存并即时更新卡面。
|
||||
- **组件**:新 `BomEstimateCard`(平台组件体系 + 白曜石规范),状态只有 加载/成功/失败(可重试)。
|
||||
- **现有工作台**:不动,作为深度模式从卡进入。
|
||||
- **移动端**:卡、SKU 列表与图片浏览响应式。
|
||||
- 红线复检:卡面文案经过 `publicEstimateWarningText` 清洗;evidence 展开层同样不得出现来源敏感词。
|
||||
|
||||
## 7. 落地顺序(每步可独立验证)
|
||||
|
||||
| 步骤 | 内容 | 验收 |
|
||||
|---|---|---|
|
||||
| ① | 价格回退工具(P0 两个) | 对已知案例重跑,未定价行显著下降 |
|
||||
| ② | 首批编排 + EstimateCard 契约 + 按需点算与结果缓存 | 单案例 2-8 分钟出首批卡;点击未核算 SKU 增量出结果且缓存生效 |
|
||||
| ③ | 预估卡前端(含 SKU 列表与现有图片浏览)+ 三态进度 | 真人走通 粘贴→出卡→点算其他 SKU→看图核对 全流程 |
|
||||
| ④ | 回测 + shadow 接入,按品类出基线报告 | 误差分布报告(对照 ±10% 目标) |
|
||||
| ⑤ | 实测样本扩到 5-10 案例,按品类定门槛 | 达标 → 翻转默认入口;未达标 → 继续迭代 |
|
||||
|
||||
## 8. 红线(重申)
|
||||
|
||||
1. 前端永不出现 1688 相关内容;一切文本出口过脱敏契约。
|
||||
2. agent 引擎在过实测闸门前保持旁路(不替换线上行为)。
|
||||
3. 区间 + 置信度 + 不确定点是答案的一部分,任何展示形态不得省略。
|
||||
4. 单价只来自工具结果;总额只由代码汇总。
|
||||
5. 现有工作台在 v2 过门槛前不删不改语义。
|
||||
|
||||
## 9. 当前资产清单(截至本文日期)
|
||||
|
||||
已建成:agent 契约/工具层/循环/网关适配/skill 知识渲染/per-SKU runner(27 测试)、镜像与材质只读仓储、1688 中性摘要工具、板材开料工具、shadow 报表、trial 试跑脚本、生产真实案例(源氏木语 27 SKU)对照数据。
|
||||
|
||||
待建:价格回退工具(5.3 表)、case 级编排、EstimateCard 契约与前端、回测接入。
|
||||
|
||||
外部依赖:1688 新 key(当前过期,影响搜图与 A/B 实测,不影响 ①② 开发)。
|
||||
@ -1,43 +0,0 @@
|
||||
# BOM 成本预估产品口径
|
||||
|
||||
本文是 BOM 成本功能的产品口径裁决入口。代码、界面、测试或历史文档与本文冲突时,应先按本文区分“成本预估”与旧链路内部一致性检查,再决定是否修改实现。
|
||||
|
||||
## 产品承诺
|
||||
|
||||
- 本功能提供带区间、置信度和待确认项的成本预估,不提供报价级精确核算。
|
||||
- 预估结果允许与实际采购价、旧规则结果或其他参考信号存在明显差异;差异本身不构成失败。
|
||||
- 缺少依据的单价可以为空,未解决行应进入待确认项,不得为了提高覆盖率编造价格。
|
||||
- Agent 决定查看什么资料、调用什么工具以及引用什么证据;总额、损耗、开料、分位数和行小计由确定性代码计算。
|
||||
- 正式 Agent 引擎以公司历史 BOM 镜像、材质目录和损耗经验为主要价格依据;临时交付阶段按下节的整件九折方案展示单一预估结果。
|
||||
|
||||
## 临时整件九折方案
|
||||
|
||||
- 正式 Agent 引擎完善前,相似度合格的实时同款报价取中位数并固定乘以 `0.90`,作为前端唯一整件预估价。
|
||||
- 没有可用同款、图片不可用或服务失败时,沿用结构预估,页面仍只显示一套结果。
|
||||
- 原始报价、候选商品、九折过程和原结构价只保留在后台审计字段;普通接口和前端不得出现来源名称、商品链接、供应商或采购系数。
|
||||
- 临时整件价生效时,前端不得同时显示结构分类金额、行小计、贡献占比或旧规则价格;BOM 部件、材质、尺寸和数量仍可在详情中查看。
|
||||
- 正式 Agent 引擎通过产品验收后,在保持同一前端单结果契约的前提下替换临时价格来源。
|
||||
|
||||
## 验收口径
|
||||
|
||||
Agent 旁路试验主要验证:
|
||||
|
||||
1. 能否稳定完成工具调用并返回符合契约的结果。
|
||||
2. 区间、置信度、证据和待确认项是否完整且容易理解。
|
||||
3. 单价是否来自工具结果,总额是否由代码汇总。
|
||||
4. 未定价行是否明确保留,而不是被虚构金额掩盖。
|
||||
5. 前端是否保持来源中性,不泄露供应商、店铺、商品链接或实时市场来源。
|
||||
|
||||
Agent 与旧引擎、实际采购价或市场信号的差异仅作为观察数据,不设全局固定百分比通过门槛。按品类统计差异可以帮助理解结果分布,但不能自动判定预估失败。
|
||||
|
||||
## 旧 10% 门槛的边界
|
||||
|
||||
`backtest:bom-cost-reference` 中的 10% 只用于验证同一历史参考镜像内“已存总额”和“逐行确定性重建”是否一致,属于数据与重建逻辑检查。它不评价页面成本预估或 Agent 预估准确度,不得显示为产品目标,也不得复用于 Agent 灰度验收。
|
||||
|
||||
数据库中的 `targetErrorPercent` 暂时保留用于旧数据和接口兼容;新界面不得展示为成本预估承诺。后续清理旧模型时再通过正式 migration 移除。
|
||||
|
||||
## 术语
|
||||
|
||||
- 用户可见名称统一使用“BOM 成本预估”“预估结果”“重新预估”。
|
||||
- `bom-costing` 等技术目录、路由和历史模型名暂时保留,避免为改名引入无关迁移。
|
||||
- “核算”仅可用于描述已经确认的历史采购/生产成本,不能描述本功能输出。
|
||||
@ -78,13 +78,9 @@
|
||||
# 是否启用 OA 看板钉钉待办
|
||||
DINGTALK_TODO_ENABLED=false
|
||||
|
||||
# 钉钉开放平台 API 应用配置(待办、审批等服务端调用)
|
||||
DINGTALK_API_CLIENT_ID=
|
||||
DINGTALK_API_CLIENT_SECRET=
|
||||
|
||||
# 钉钉网页登录使用独立 OAuth 应用配置,不与 API 应用混用
|
||||
DINGTALK_LOGIN_CLIENT_ID=
|
||||
DINGTALK_LOGIN_CLIENT_SECRET=
|
||||
# 钉钉应用基础配置
|
||||
DINGTALK_CLIENT_ID=
|
||||
DINGTALK_CLIENT_SECRET=
|
||||
DINGTALK_CALLBACK_ORIGIN=
|
||||
|
||||
# 待办跳转回系统的基础地址
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 供应链产品全量档案字段来源对照表
|
||||
|
||||
更新时间:2026-10-16
|
||||
更新时间:2026-08-10
|
||||
|
||||
## 1. 合并与追踪规则
|
||||
|
||||
@ -8,8 +8,8 @@
|
||||
- 唯一合并键:`barcode`
|
||||
- 来源记录:`SupplyChainProductArchiveSource`
|
||||
- 字段级来源:`SupplyChainProductArchive.fieldSourceMap`
|
||||
- 产品全量档案页面使用的来源数据必须先写入 `SupplyChainProductArchive` 或图片缓存;页面列表不再临时联查来源库。
|
||||
- 新品图片先用 `external.product_catalog_wide_v1.archive_image` 判断当前 SKU/条码行是否真的有图;确认有图后,保存同一行标准化的绝对 `image_url`,绝对地址为空时才保留 `archive_image`。当 `archive_image` 为空时禁止读取 `image_url`,因为该列此时可能回退到产品级图片。宽表当前行无图时,再按当前条码/SKU精确查询素材库;命中的链接和来源信息保存在 `SupplyChainProductArchiveImageCache`。图片二进制不写入 PostgreSQL,图片变化不进入商品修改日志。
|
||||
- 除图片外,产品全量档案页面使用的来源数据必须先写入 `SupplyChainProductArchive`;页面列表不再临时联查来源库。
|
||||
- 图片由素材库接口按条码/SKU获取;匹配结果的链接和来源信息保存在独立的 `SupplyChainProductArchiveImageCache` 表,不把图片二进制写进 PostgreSQL,也不进入商品修改日志。历史 `image` 值只作兼容兜底,不再由后续档案同步更新。
|
||||
- 首次建立商品档案只记录一条“商品档案导入”;后续来源值实际变化时,按条码、字段、旧值、新值和来源写入 `OperationLog`。
|
||||
- 扩展字段通过 `sync:supply-chain-product-archive-enrichment` 分批同步;重复执行时只有值发生变化的字段才更新主表并写日志。
|
||||
- 生产定时入口为 `scripts/install-supply-chain-product-archive-sync-timer.sh`,默认每 15 分钟先同步 `rpt.jst_sku` 基础档案,再同步供应商、仓库、生产周期、日销和价格等扩展字段。
|
||||
@ -29,7 +29,7 @@
|
||||
| `barcode` | 条码 | `rpt.jst_sku.sku_id` | `external.product_catalog_wide_v1.barcode_business_key` | | 去空格并转大写;档案唯一键 |
|
||||
| `spu` | SPU | `rpt.jst_sku.i_id` | `archive_spu` | | 去空格并转大写 |
|
||||
| `sku` | SKU | `rpt.jst_sku.other_7` | `archive_sku` | | 去空格并转大写;JST 空值不兜底 |
|
||||
| `image` | 图片 | | `external.product_catalog_wide_v1.archive_image` + `image_url` | 素材库接口 + `SupplyChainProductArchiveImageCache.previewUrl` | 新品以当前 SKU/条码行 `archive_image` 非空为前提,保存同一行标准化 `image_url`;`archive_image` 为空时不得使用可能回退到产品级图片的 `image_url`;当前行无图时再按当前条码/SKU精确查询素材库,命中链接长期复用,未命中则留空 |
|
||||
| `image` | 图片 | | | 素材库接口 + `SupplyChainProductArchiveImageCache.previewUrl` | 条码优先、SKU 图兜底、最后白底图;成功链接长期复用,未命中 24 小时后可重试,不写档案主表 |
|
||||
| `businessCreatedAt` | 创建时间 | `rpt.jst_sku.created` | `product_development_created_at`,空时取 `source_created_at` | 云杉文件“创建时间”按条码匹配 | 按固定优先级覆盖 |
|
||||
| `categoryL1` | 一级类目 | | `sales_category_name` | `SupplyChainProductArchiveCategoryConfig.categoryL1` | JST 行按完整二级类目匹配配置表 |
|
||||
| `categoryL2` | 二级类目 | `rpt.jst_sku.category` | `archive_product_category_name` | | JST 原样保留完整类目文本 |
|
||||
@ -70,7 +70,7 @@
|
||||
| `sales7d` | 7日日销 | | | `rpt.pm_stockMonitor.sevenAvgSale` | 按条码匹配最新记录 |
|
||||
| `sales15d` | 15日日销 | | | `rpt.pm_stockMonitor.fifteenAvgSale` | 按条码匹配最新记录 |
|
||||
| `purchasePrice` | 采购价 | `rpt.jst_sku.purchase_price` | | | 按档案条码精确匹配 `jst_sku.sku_id`;匹配不到时留空 |
|
||||
| `costPrice` | 成本价(聚水潭) | `rpt.jst_sku.cost_price` | | | 按档案条码精确匹配 `jst_sku.sku_id`;匹配不到时留空。DF 条码自 2026-10-16 起同样取本来源(原 `rpt.dws_sn_sales_full_info_di.goods_cost` 为金额口径,已停用) |
|
||||
| `costPrice` | 成本价(聚水潭) | `rpt.jst_sku.cost_price` | | | 按档案条码精确匹配 `jst_sku.sku_id`;匹配不到时留空 |
|
||||
| `costPriceSecondary` | 成本价(新品) | | `external.product_catalog_wide_v1.cost_price` | | 按档案条码精确匹配 `barcode_business_key`,只读取 `catalog_source = INTAKE_READY` 的新品行;匹配不到或来源为空时留空 |
|
||||
|
||||
## 3. JST_SKU 第一批字段覆盖率
|
||||
@ -118,4 +118,4 @@
|
||||
- 聚水潭及其关联表由 systemd timer 周期检查。同步过程可以重复执行,主表只写实际变化,商品修改日志只记录后续真实变化。
|
||||
- 定时任务安装前必须在生产服务器的 `shared/supply-chain-product-archive-sync.env` 填写目标 PostgreSQL 和来源 SQL Server 的身份校验值;文件权限固定为 `600`,其中不保存数据库密码。
|
||||
- 安装命令只在发布完成并获得生产操作授权后执行:`bash scripts/install-supply-chain-product-archive-sync-timer.sh`。
|
||||
- 新品即时同步以宽表当前 SKU/条码行的 `archive_image` 是否存在为准,并把同一行标准化的绝对 `image_url` 写入档案和 `SupplyChainProductArchiveImageCache`。宽表当前行无图时,页面才按当前条码/SKU调用素材库接口并回写链接;存量图片规则不在本次新品图片调整范围内。
|
||||
- 图片不属于此定时任务。页面优先读取 `SupplyChainProductArchiveImageCache`,没有缓存时才按条码/SKU调用素材库接口并回写链接。
|
||||
|
||||
@ -1,363 +0,0 @@
|
||||
# 店铺账号管理取数与筛选逻辑
|
||||
|
||||
> 用途:给需要接入店铺账号管理数据的开发人员使用。本文描述当前代码和数据库的真实口径。
|
||||
>
|
||||
> 重要:本文不包含数据库连接串、账号、密码、加密密钥或任何业务明文数据。当前数据由本系统人工维护,不直接从外部数据库同步。
|
||||
|
||||
## 1. 数据源与责任边界
|
||||
|
||||
- **权威数据源**:本系统使用 `DATABASE_URL` 指向的 PostgreSQL 数据库,数据通过本系统店铺账号管理页面/API 人工新增、编辑、导入和维护。
|
||||
- **不属于数据源的内容**:聚水潭、京东、京喜等外部系统目前不是本模块的自动同步来源;不能假设外部系统中存在同一份实时数据。
|
||||
- **推荐接入方式**:优先调用本系统鉴权 API。若确实需要数据库直读,应由数据库管理员单独创建只读账号或只读视图,并按本文的字段白名单开放;不要复用应用账号,也不要把应用数据库密码或加密密钥交给外部系统。
|
||||
- **人工维护原则**:不要直接写入业务表绕过 Service。直接写入会跳过页面权限、字段权限、密文加密、唯一键校验和操作日志。
|
||||
|
||||
### 1.1 代码与迁移依据
|
||||
|
||||
当前数据契约以以下文件为准:
|
||||
|
||||
- Prisma 模型:`prisma/schema.prisma` 中 `StoreAccount`、`StoreAccountStoreProfile`、`StoreAccountChangeLog`、`StoreAccountFieldPermission`、`SupplyChainPermissionOverride`。
|
||||
- 建表迁移:`prisma/migrations/20260826120000_add_store_account_management/migration.sql`。
|
||||
- 店铺主体和银行卡字段、平台资料表迁移:`prisma/migrations/20260828100000_extend_store_account_profiles/migration.sql`。
|
||||
- 列表筛选规则:`src/server/repositories/store-account-management.repository.ts`。
|
||||
- 业务校验、脱敏和日志:`src/server/services/store-account-management.service.ts`。
|
||||
- 导出字段:`src/server/services/store-account-management-export.service.ts`。
|
||||
|
||||
## 2. 物理表清单与关联关系
|
||||
|
||||
PostgreSQL 物理表名使用双引号保留大小写,取数时应写成下表中的名称。
|
||||
|
||||
| 物理表 | 用途 | 主要关联键 | 是否主业务数据 |
|
||||
| --- | --- | --- | --- |
|
||||
| `"StoreAccount"` | 店铺账号主记录,一行代表一个店铺下的一种账号 | `id`;业务唯一键为 `storeName + accountType + account` | 是 |
|
||||
| `"StoreAccountStoreProfile"` | 京东/京喜店铺资料,一行代表一个店铺的一个平台资料 | 逻辑关联 `storeName`,平台由 `platform` 区分 | 是(扩展资料) |
|
||||
| `"StoreAccountChangeLog"` | 新增、编辑、导入、查看敏感字段、资料维护的审计日志 | `storeAccountId` 可为空;资料日志用 `storeName` | 否(审计) |
|
||||
| `"StoreAccountFieldPermission"` | 店铺账号字段对业务类型/个人的可见、只读、可编辑配置 | `fieldKey + subjectKey` 唯一 | 否(权限配置) |
|
||||
| `"SupplyChainPermissionOverride"` | 页面级的查看、下载、编辑权限 | `targetKey + subjectKey + action` 唯一;本模块 `targetKey = 'storeAccountManagement'` | 否(权限配置) |
|
||||
|
||||
`StoreAccountStoreProfile` 与 `StoreAccount` 没有数据库外键,应用层按 `storeName` 关联。因此外部查询时要注意:资料表可能没有对应账号行,也可能一个店铺有多个账号行;不要用 `id` 去连接资料表。
|
||||
|
||||
## 3. `"StoreAccount"` 字段字典
|
||||
|
||||
| 字段 | PostgreSQL 类型/约束 | 业务含义 | 空值与敏感性 |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `TEXT`,主键 | 账号记录 ID | 系统生成,不为空 |
|
||||
| `storeName` | `TEXT NOT NULL` | 店铺名称,也是资料表的逻辑关联键 | 不应为空;支持多选筛选 |
|
||||
| `operationOwner` | `TEXT NOT NULL DEFAULT ''` | 店铺运营负责人 | 空字符串表示未维护 |
|
||||
| `operationDepartmentHead` | `TEXT NOT NULL DEFAULT ''` | 店铺运营部门负责人 | 空字符串表示未维护 |
|
||||
| `operationDirector` | `TEXT NOT NULL DEFAULT ''` | 店铺运营总监 | 空字符串表示未维护 |
|
||||
| `profitCenter` | `TEXT NOT NULL DEFAULT ''` | 店铺所属利润中心 | 空字符串或:`运营中心A`、`运营中心B`、`运营中心C`、`运营中心G` |
|
||||
| `subject` | `TEXT NOT NULL DEFAULT ''` | 店铺主体 | 空字符串表示未维护 |
|
||||
| `accountType` | `TEXT NOT NULL` | 账号类型 | 正常值 `MAIN`(主账号)、`SUB`(子账号);不完整导入允许空字符串或 `/` |
|
||||
| `account` | `TEXT NOT NULL` | 登录账号 | 新增时必填 |
|
||||
| `passwordCiphertext` | `TEXT NOT NULL DEFAULT ''` | 密码密文 | 绝不作为普通取数字段返回;空字符串表示未设置 |
|
||||
| `boundBankAccountNumberCiphertext` | `TEXT NOT NULL DEFAULT ''` | 店铺绑定银行卡号密文 | 与密码同等级敏感;空字符串表示未设置 |
|
||||
| `boundPhone` | `TEXT NOT NULL DEFAULT ''` | 绑定手机号 | 空字符串表示未维护 |
|
||||
| `verificationContact` | `TEXT NOT NULL DEFAULT ''` | 手机号验证码对接人 | 空字符串表示未维护 |
|
||||
| `jstOrderService` | `TEXT NOT NULL DEFAULT ''` | 聚水潭订单服务号 | 空字符串表示未维护 |
|
||||
| `accountGroupName` | `TEXT NOT NULL DEFAULT ''` | 账号分组名称 | 空字符串表示未维护 |
|
||||
| `createdById` | `TEXT` 可空 | 首次创建人 ID | 系统审计字段 |
|
||||
| `updatedById` | `TEXT` 可空 | 最近修改人 ID | 系统审计字段 |
|
||||
| `createdAt` | `TIMESTAMP(3) NOT NULL` | 账号记录创建时间 | 系统生成;列表按此字段倒序 |
|
||||
| `updatedAt` | `TIMESTAMP(3) NOT NULL` | 账号记录最近修改时间 | 系统自动更新时间 |
|
||||
|
||||
约束和索引:
|
||||
|
||||
- 唯一约束:`("storeName", "accountType", "account")`,导入按这三个字段执行 upsert。
|
||||
- 索引:`storeName`、`account`、`accountType`、`updatedAt`。
|
||||
- 本表没有软删除字段;空白字段是空字符串,不使用“系统默认值”兜底。
|
||||
|
||||
## 4. `"StoreAccountStoreProfile"` 字段字典
|
||||
|
||||
该表用于“京东/京喜店铺资料”次页面的后续维护,不要求在账号主表导入时同时填写。
|
||||
|
||||
| 字段 | PostgreSQL 类型/约束 | 业务含义 | 取值规则 |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `TEXT`,主键 | 店铺平台资料 ID | 系统生成 |
|
||||
| `storeName` | `TEXT NOT NULL` | 店铺名称 | 与 `StoreAccount.storeName` 逻辑关联 |
|
||||
| `platform` | `TEXT NOT NULL` | 平台代码 | `JD`=京东,`JX`=京喜 |
|
||||
| `merchantNumber` | `TEXT NOT NULL DEFAULT ''` | 商户号(钱包商户号) | 空字符串表示未维护 |
|
||||
| `secondaryMerchantNumber` | `TEXT NOT NULL DEFAULT ''` | 二级商户号 | 空字符串表示未维护 |
|
||||
| `merchantStoreId` | `TEXT NOT NULL DEFAULT ''` | 商家 ID(钱包店铺号) | 空字符串表示未维护 |
|
||||
| `companyId` | `TEXT NOT NULL DEFAULT ''` | 公司 ID(京麦店铺 ID) | 空字符串表示未维护 |
|
||||
| `createdById` | `TEXT` 可空 | 首次创建人 ID | 系统审计字段 |
|
||||
| `updatedById` | `TEXT` 可空 | 最近修改人 ID | 系统审计字段 |
|
||||
| `createdAt` | `TIMESTAMP(3) NOT NULL` | 资料创建时间 | 系统生成 |
|
||||
| `updatedAt` | `TIMESTAMP(3) NOT NULL` | 资料最近修改时间 | 系统自动更新时间 |
|
||||
|
||||
约束和平台规则:
|
||||
|
||||
- 唯一约束:`("storeName", "platform")`,同一店铺同一平台只有一条资料。
|
||||
- 店铺名以 `京东-` 开头时只能使用 `JD`;以 `京喜-` 开头时只能使用 `JX`。
|
||||
- 非 `京东-` 或 `京喜-` 开头的店铺不支持店铺资料页面/API,服务端会拒绝读取或保存。
|
||||
- 资料字段通过 `storeName` 关联账号主表,导出时会按平台展开为京东四列和京喜四列。
|
||||
|
||||
## 5. `"StoreAccountChangeLog"` 字段与日志格式
|
||||
|
||||
| 字段 | PostgreSQL 类型/约束 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `TEXT`,主键 | 日志 ID |
|
||||
| `storeAccountId` | `TEXT` 可空 | 账号记录 ID;店铺资料变更时为空 |
|
||||
| `storeName` | `TEXT NOT NULL` | 店铺名称 |
|
||||
| `operation` | `TEXT NOT NULL` | 操作类型 |
|
||||
| `actorId` | `TEXT` 可空 | 操作人 ID |
|
||||
| `actorName` | `TEXT NOT NULL` | 操作人名称 |
|
||||
| `changes` | `JSONB NOT NULL` | 字段变更明细数组 |
|
||||
| `createdAt` | `TIMESTAMP(3) NOT NULL` | 日志时间 |
|
||||
|
||||
当前 `operation` 代码:
|
||||
|
||||
| operation | 含义 |
|
||||
| --- | --- |
|
||||
| `CREATE` | 新增账号 |
|
||||
| `UPDATE` | 编辑账号 |
|
||||
| `IMPORT_CREATE` | 导入新增 |
|
||||
| `IMPORT_UPDATE` | 导入更新 |
|
||||
| `BATCH_UPDATE` | 批量修改 |
|
||||
| `VIEW_PASSWORD` | 查看密码 |
|
||||
| `VIEW_BANK_ACCOUNT` | 查看银行卡号 |
|
||||
| `PROFILE_UPDATE` | 修改京东/京喜店铺资料 |
|
||||
|
||||
`changes` 的标准元素为:
|
||||
|
||||
```json
|
||||
{
|
||||
"field": "operationOwner",
|
||||
"previousValue": "原值",
|
||||
"nextValue": "新值"
|
||||
}
|
||||
```
|
||||
|
||||
特殊字段和资料字段:
|
||||
|
||||
- `password`、`boundBankAccountNumber` 只记录“已设置/未设置”,不写入明文或密文。
|
||||
- 查看敏感字段时记录 `nextValue: "已查看"`。
|
||||
- 平台资料字段使用 `JD.merchantNumber`、`JX.companyId` 这样的字段名。
|
||||
- API 返回日志前还会根据当前调用人的字段权限过滤隐藏字段。
|
||||
|
||||
## 6. `"StoreAccountFieldPermission"` 字段字典
|
||||
|
||||
| 字段 | PostgreSQL 类型/约束 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `TEXT`,主键 | 权限记录 ID |
|
||||
| `fieldKey` | `TEXT NOT NULL` | 店铺账号字段 key,如 `account`、`password`、`boundBankAccountNumber` |
|
||||
| `subjectKey` | `TEXT NOT NULL` | 业务类型或个人主体 |
|
||||
| `visibility` | `TEXT NOT NULL DEFAULT 'READONLY'` | `HIDDEN`、`READONLY` 或 `EDITABLE` |
|
||||
| `updatedById` | `TEXT` 可空 | 最后配置人 ID |
|
||||
| `createdAt` / `updatedAt` | `TIMESTAMP(3)` | 权限记录时间 |
|
||||
|
||||
当前可配置的业务类型 `subjectKey`:
|
||||
|
||||
`design`(设计)、`planning`(企划)、`brandCenter`(策划)、`purchase`(采购)、`merchandiser`(采购-跟单)、`pricing`(采购-核价)、`quality`(采购-质检)、`operation`(运营)、`warehouse`(仓库)、`customerService`(客服)、`management`(经管)、`viewer`(普通用户)。
|
||||
|
||||
个人例外使用 `user@<用户ID>`。唯一约束为 `("fieldKey", "subjectKey")`。`createdAt`、`updatedAt` 是系统字段,不能通过字段权限配置为可编辑。
|
||||
|
||||
## 7. 页面级权限表
|
||||
|
||||
`"SupplyChainPermissionOverride"` 的 `targetKey` 固定为 `storeAccountManagement`,`action` 为 `view`、`download`、`edit`。
|
||||
|
||||
| 场景 | 当前规则 |
|
||||
| --- | --- |
|
||||
| 未登录 | 所有店铺账号接口返回 401 |
|
||||
| 页面查看 | 需要工作台可见且 `storeAccountManagement:view` 生效 |
|
||||
| 新增、编辑、导入、批量修改 | 需要页面 `edit` |
|
||||
| 导出全部或已选 | 需要页面可见,且调用人是管理员或经管 |
|
||||
| 配置字段权限 | 需要页面可见,且调用人是管理员或经管 |
|
||||
| 密码/银行卡查看 | 还要通过对应字段权限;服务端单独记录查看日志 |
|
||||
|
||||
字段可见性解析顺序:
|
||||
|
||||
1. 页面没有 `view` 时,所有字段均为 `HIDDEN`。
|
||||
2. 先取页面编辑能力形成基础值:系统时间字段只读,密码和银行卡默认隐藏,普通字段按页面编辑能力为可编辑或只读。
|
||||
3. 同一字段的业务类型配置按 `EDITABLE > READONLY > HIDDEN` 合并。
|
||||
4. `user@<用户ID>` 的个人配置覆盖业务类型配置。
|
||||
5. 没有页面编辑权时,即使字段配置为 `EDITABLE` 也会降为只读;系统时间字段永远不会变成可编辑。
|
||||
|
||||
列表接口对隐藏字段返回空字符串;密码和银行卡只返回 `passwordConfigured`、`boundBankAccountNumberConfigured` 布尔值,不返回密文。
|
||||
|
||||
## 8. 列表取数与筛选逻辑
|
||||
|
||||
### 8.1 API 参数
|
||||
|
||||
`GET /api/supply-chain/store-account-management`
|
||||
|
||||
| 参数 | 默认/限制 | 逻辑 |
|
||||
| --- | --- | --- |
|
||||
| `page` | 默认 `1`,最小 `1` | 页码 |
|
||||
| `pageSize` | 默认 `50`,范围 `1..200` | 每页行数;页面常用 50/100/200 |
|
||||
| `search` | 最多 120 字符 | 关键词,可用空格、逗号、中文逗号、分号分隔多个 token |
|
||||
| `storeName` | 最多 120 字符 | 店铺名称多选值,同样支持逗号、中文逗号、分号分隔 |
|
||||
| `accountType` | 空、`MAIN`/`SUB` 或显示名 | 包含匹配;页面显示主账号/子账号 |
|
||||
| `operationOwner` | 空或文本 | 店铺运营负责人包含匹配 |
|
||||
| `accountGroupName` | 空或文本 | 账号分组名称包含匹配 |
|
||||
| `createdAtGranularity` | `year`、`month`、`day` | 创建时间筛选粒度 |
|
||||
| `createdAtValue` | 年 `YYYY`;月 `YYYY-MM`;日 `YYYY-MM-DD` | 与粒度配套的值 |
|
||||
|
||||
### 8.2 条件组合
|
||||
|
||||
- 不同筛选项之间是 **AND**。
|
||||
- `search` 内部每个 token 会对以下字段做 **OR**:`storeName`、`account`、`operationOwner`、`operationDepartmentHead`、`operationDirector`、`profitCenter`、`subject`、`boundPhone`、`verificationContact`、`jstOrderService`、`accountGroupName`。
|
||||
- `search` 不搜索密码、银行卡、创建时间、修改时间和京东/京喜资料字段。
|
||||
- `storeName` 会拆分并去重,使用精确 `IN` 匹配;最多取 500 个店铺名。
|
||||
- `accountType`、`operationOwner`、`accountGroupName` 使用不区分大小写的 `contains` 匹配。
|
||||
- 创建时间使用中国时区 `+08:00` 的半开区间:`[开始时间, 结束时间)`。例如 `2026-08` 是 `2026-08-01 00:00:00+08:00`(含)到 `2026-09-01 00:00:00+08:00`(不含)。
|
||||
|
||||
### 8.3 排序与分页
|
||||
|
||||
固定排序:
|
||||
|
||||
```sql
|
||||
ORDER BY "createdAt" DESC, "id" ASC
|
||||
```
|
||||
|
||||
因此最新创建的记录永远在前面;`id ASC` 只用于创建时间相同时稳定排序。分页使用 `OFFSET (page - 1) * pageSize LIMIT pageSize`,同时查询总数。
|
||||
|
||||
成功响应核心结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"rows": [],
|
||||
"storeNames": ["京东-示例店"],
|
||||
"pageInfo": { "page": 1, "pageSize": 50, "total": 0, "totalPages": 1 },
|
||||
"fieldAccess": {}
|
||||
},
|
||||
"meta": { "requestId": "...", "timestamp": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
`storeNames` 是全表去重后按名称升序返回的店铺列表,用于前端多选;它不受当前页面筛选条件限制。
|
||||
|
||||
## 9. 只读 SQL 示例
|
||||
|
||||
以下 SQL 仅用于说明字段和筛选口径,执行账号必须是单独的只读账号。示例不包含密码密文列。
|
||||
|
||||
### 9.1 最新账号列表
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
"id", "storeName", "operationOwner", "operationDepartmentHead",
|
||||
"operationDirector", "profitCenter", "subject", "accountType",
|
||||
"account", "boundPhone", "verificationContact", "jstOrderService",
|
||||
"accountGroupName", "createdAt", "updatedAt"
|
||||
FROM "StoreAccount"
|
||||
ORDER BY "createdAt" DESC, "id" ASC
|
||||
LIMIT 50 OFFSET 0;
|
||||
```
|
||||
|
||||
### 9.2 店铺 + 账号类型 + 月份筛选
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
"id", "storeName", "accountType", "account", "createdAt", "updatedAt"
|
||||
FROM "StoreAccount"
|
||||
WHERE "storeName" IN ('京东-示例店', '京喜-示例店')
|
||||
AND "accountType" = 'MAIN'
|
||||
AND "createdAt" >= TIMESTAMPTZ '2026-08-01 00:00:00+08:00'
|
||||
AND "createdAt" < TIMESTAMPTZ '2026-09-01 00:00:00+08:00'
|
||||
ORDER BY "createdAt" DESC, "id" ASC;
|
||||
```
|
||||
|
||||
### 9.3 关键词筛选的 SQL 等价写法
|
||||
|
||||
```sql
|
||||
WHERE (
|
||||
"storeName" ILIKE '%' || $1 || '%'
|
||||
OR "account" ILIKE '%' || $1 || '%'
|
||||
OR "operationOwner" ILIKE '%' || $1 || '%'
|
||||
OR "operationDepartmentHead" ILIKE '%' || $1 || '%'
|
||||
OR "operationDirector" ILIKE '%' || $1 || '%'
|
||||
OR "profitCenter" ILIKE '%' || $1 || '%'
|
||||
OR "subject" ILIKE '%' || $1 || '%'
|
||||
OR "boundPhone" ILIKE '%' || $1 || '%'
|
||||
OR "verificationContact" ILIKE '%' || $1 || '%'
|
||||
OR "jstOrderService" ILIKE '%' || $1 || '%'
|
||||
OR "accountGroupName" ILIKE '%' || $1 || '%'
|
||||
)
|
||||
```
|
||||
|
||||
多个 token 时,把每个 token 的上述括号用 `AND` 连接。
|
||||
|
||||
### 9.4 查询平台资料
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
"storeName", "platform", "merchantNumber", "secondaryMerchantNumber",
|
||||
"merchantStoreId", "companyId", "createdAt", "updatedAt"
|
||||
FROM "StoreAccountStoreProfile"
|
||||
WHERE "storeName" = $1
|
||||
AND "platform" IN ('JD', 'JX')
|
||||
ORDER BY "platform" ASC;
|
||||
```
|
||||
|
||||
资料缺失时不要补默认值;没有行或字段为空都表示尚未维护。
|
||||
|
||||
### 9.5 查询审计日志
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
"id", "storeAccountId", "storeName", "operation",
|
||||
"actorId", "actorName", "changes", "createdAt"
|
||||
FROM "StoreAccountChangeLog"
|
||||
WHERE "storeName" = $1
|
||||
ORDER BY "createdAt" DESC
|
||||
LIMIT 200;
|
||||
```
|
||||
|
||||
## 10. 当前 API 接口清单
|
||||
|
||||
所有接口都要求登录,并返回统一的 `{ success, data/error, meta }` 包装。接口权限还会检查工作台权限和 `storeAccountManagement` 页面权限。
|
||||
|
||||
| 方法 | 路径 | 用途 | 主要限制 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/api/supply-chain/store-account-management` | 分页列表、筛选、店铺名称选项 | 页面 `view` |
|
||||
| `POST` | `/api/supply-chain/store-account-management` | 新增账号 | 页面 `edit`;店铺名、账号类型、账号必填 |
|
||||
| `GET` | `/api/supply-chain/store-account-management/{id}` | 单条账号 | 页面 `view` |
|
||||
| `PATCH` | `/api/supply-chain/store-account-management/{id}` | 编辑账号 | 页面 `edit`;只更新有权限的字段 |
|
||||
| `POST` | `/api/supply-chain/store-account-management/import` | Excel/CSV 导入账号 | 页面 `edit`;10 MB、5000 行;NDJSON 进度 |
|
||||
| `POST` | `/api/supply-chain/store-account-management/batch` | 批量修改前预览 | 页面 `edit` + 目标字段 `EDITABLE`;最多 500 条 |
|
||||
| `PATCH` | `/api/supply-chain/store-account-management/batch` | 批量修改 | 同上;密码、银行卡和系统时间不可批量修改 |
|
||||
| `GET` | `/api/supply-chain/store-account-management/export` | 导出全部账号 | 仅管理员或经管;包含明文密码/银行卡,必须严格控制调用方 |
|
||||
| `GET` | `/api/supply-chain/store-account-management/export?scope=selected&ids=id1,id2` | 导出已选账号 | 仅管理员或经管;最多 200 个 ID |
|
||||
| `GET` | `/api/supply-chain/store-account-management/logs?id=...` | 查询单账号日志 | 页面 `view`;返回内容按字段权限过滤 |
|
||||
| `GET` | `/api/supply-chain/store-account-management/logs?storeName=...` | 查询店铺日志 | 页面 `view`;最多返回 200 条 |
|
||||
| `GET` | `/api/supply-chain/store-account-management/{id}/password` | 查看密码明文 | 字段可见;响应不缓存,并写查看日志 |
|
||||
| `GET` | `/api/supply-chain/store-account-management/{id}/bank-account` | 查看银行卡号明文 | 字段可见,并写查看日志 |
|
||||
| `GET` | `/api/supply-chain/store-account-management/permissions` | 读取字段权限配置 | 仅管理员或经管 |
|
||||
| `PUT` | `/api/supply-chain/store-account-management/permissions` | 保存字段权限配置 | 仅管理员或经管;最多 500 条更新 |
|
||||
| `GET` | `/api/supply-chain/store-account-management/profiles?storeName=...` | 读取京东/京喜店铺资料 | 页面 `view`;店铺名前缀必须匹配 |
|
||||
| `PATCH` | `/api/supply-chain/store-account-management/profiles` | 保存平台资料 | 页面 `edit`;平台必须与店铺名前缀一致 |
|
||||
| `GET` | `/api/supply-chain/store-account-management/profiles/template?platform=JD\|JX` | 下载平台资料模板 | 页面 `edit` |
|
||||
| `POST` | `/api/supply-chain/store-account-management/profiles/import` | 导入平台资料 | 页面 `edit`;表单字段 `file` + `platform`,10 MB、5000 行 |
|
||||
|
||||
导出文件为 XLSX,包含账号主表字段、解密后的密码和绑定银行卡号,以及按 `JD`/`JX` 展开的八个店铺资料字段。外部系统若不需要敏感字段,不应调用导出接口,应该调用列表接口或使用只读白名单视图。
|
||||
|
||||
## 11. 导入、更新和空白值规则
|
||||
|
||||
- 主表导入支持中文表头和内部英文 key;未知列会被忽略。
|
||||
- 行数上限 5000,文件上限 10 MB;每一行按 `storeName + accountType + account` upsert,并写入 `IMPORT_CREATE` 或 `IMPORT_UPDATE` 日志。
|
||||
- 普通文本字段导入空白会保存为空字符串,表示“未维护”;不会套用系统默认值。
|
||||
- 密码和绑定银行卡号为空时不会覆盖已有密文;要清除敏感值需由具备相应字段编辑权限的业务流程明确处理,不能通过普通空白导入误清除。
|
||||
- 新增账号时 `storeName`、`accountType`、`account` 必填,`accountType` 正常应为 `MAIN` 或 `SUB`。
|
||||
- 批量修改会先返回所选记录当前值预览,再统一更新;每条实际发生变化的记录写一条 `BATCH_UPDATE` 日志。
|
||||
- 平台资料导入只识别“店铺名称”和四个资料字段,按店铺名和平台 upsert;空白资料仍然允许保存。
|
||||
|
||||
## 12. 敏感字段与外部接入注意事项
|
||||
|
||||
1. `passwordCiphertext` 和 `boundBankAccountNumberCiphertext` 使用 AES-256-GCM 存储,密钥来自服务端 `STORE_ACCOUNT_ENCRYPTION_KEY`(生产必须配置至少 32 个字符)。
|
||||
2. 外部系统不能自行解密,也不能索要或复制该密钥;密文列不应进入普通数据同步任务、日志或缓存。
|
||||
3. 普通列表接口只返回“是否已设置”布尔值;明文只通过受权限保护的查看接口或管理员/经管导出得到,并且会留下审计日志。
|
||||
4. 字段权限和页面权限不是同一层:先满足页面 `view/edit`,再应用业务类型和个人字段权限。
|
||||
5. 直连数据库时建议只开放不敏感白名单字段,至少排除两个 `*Ciphertext` 字段、权限表和日志中的敏感上下文;如需日志,应单独授予审计读取权限。
|
||||
6. 当前没有外部只读视图或公开 Token 契约。外部接入前应由系统负责人确认鉴权方式、限流、字段白名单、数据刷新频率和脱敏要求。
|
||||
|
||||
## 13. 接入前检查清单
|
||||
|
||||
- [ ] 确认读取的是当前 `DATABASE_URL` 对应的 PostgreSQL,而不是历史数据库或外部平台库。
|
||||
- [ ] 使用本文中的双引号物理表名和字段名,确认迁移已执行。
|
||||
- [ ] 明确只读范围:主表、平台资料、日志、权限配置是否都需要。
|
||||
- [ ] 明确是否需要京东/京喜资料,按 `storeName + platform` 读取,不能按账号 `id` 连接。
|
||||
- [ ] 明确筛选时区为中国 `+08:00`,创建时间使用半开区间。
|
||||
- [ ] 按 `createdAt DESC, id ASC` 保持与页面相同的排序。
|
||||
- [ ] 不读取或传播密码、银行卡密文;需要明文时走授权接口并保留审计记录。
|
||||
- [ ] 不直接写表;新增、编辑、导入和权限变更必须走系统 API 或经负责人批准的维护流程。
|
||||
@ -1,289 +0,0 @@
|
||||
# 物流到货签收时长看板 · 订单明细每日快照
|
||||
|
||||
## 为什么要做
|
||||
|
||||
看板的全部数据来自远端只读报表视图 `rpt.jst_order_express_details`(外加 `jst_qmcust_orderlist_item` 条码明细和 `SupplyChainProductArchive` 产品档案富化)。
|
||||
该视图是远端对象,一次扫描通常以分钟计,页面每次刷新、翻页、下钻、导出都要重新扫一遍,看板因此很难"高效运转"。
|
||||
|
||||
做法:每天把看板口径的订单明细物化到本地 Postgres,看板优先读本地快照,只有快照覆盖不到时才回落实时数据源。
|
||||
|
||||
## 数据与表
|
||||
|
||||
### 条码/供应商/采购的匹配来源(两套互补的订单表)
|
||||
|
||||
报表库把订单明细拆成了两套**互不重叠**的表,只看一套会漏掉近一半订单的条码:
|
||||
|
||||
| 表 | 覆盖平台 | 规模(实测) |
|
||||
| --- | --- | --- |
|
||||
| `rpt.jst_qmcust_orderlist` / `rpt.jst_qmcust_orderlist_item` | 淘宝天猫、阿里巴巴(1688)、淘工厂、天猫供销 | 408 万 / 576 万行 |
|
||||
| `rpt.jst_Orders` / `rpt.jst_OrderItem` | 京东商城、京东厂家直送、拼多多、头条放心购(抖音)、得物、唯品会、线下、微信视频号、小红书 | 124 万 / 175 万行 |
|
||||
|
||||
看板取条码时会**同时查两张明细表并用 `INNER_ORDER_ID`(即 `o_id`)合并**(`listItemRows`,两表按订单号各发一批查询后合并结果)。修复前只查 `jst_qmcust_orderlist_item`,导致京东/拼多多/抖音/得物/唯品会的订单条码全空;修复后新生成的快照条码匹配率为 **100%**(此前整体 52.3%)。
|
||||
|
||||
> 注:`rpt.jst_salesOutboundInfo`(销售出库单头)可以读取,但只有 `o_id / so_id / l_id / shop_name / io_id / qty` 等出库单头字段,没有 SKU 列,不能单独提供条码。
|
||||
|
||||
纯新增两张表(迁移 `prisma/migrations/20260928120000_add_logistics_arrival_signing_duration_snapshot`):
|
||||
|
||||
| 表 | 作用 |
|
||||
| --- | --- |
|
||||
| `LogisticsArrivalSigningDurationSnapshotRun` | 批次元数据:`snapshotDate`、`windowFrom`、`windowTo`、`status`(RUNNING/COMPLETED/FAILED)、`rowCount`、`message` |
|
||||
| `LogisticsArrivalSigningDurationSnapshot` | 明细行:与看板 09 订单明细一一对应,含条码/供应商/采购/`productItems` 等快照时点已固化的富化结果 |
|
||||
|
||||
要点:
|
||||
|
||||
- 只有 `status = COMPLETED` 的批次对读路径可见,写入中途或失败的批次不会被读到。
|
||||
- 行唯一键 `(snapshotDate, rowKey)`,`rowKey` 与下钻键一致(内部单号 + 线上订单号 + 物流单号 + 签收时间),重复执行用 `skipDuplicates` 幂等。
|
||||
- `(snapshotDate, signTime)` 有索引,按签收日期区间取数走索引。
|
||||
|
||||
## 生成任务
|
||||
|
||||
```bash
|
||||
# 默认:快照日期=当天(Asia/Shanghai),窗口=最近 62 天(覆盖本月与上月),保留最近 2 批
|
||||
npm run sync:logistics-arrival-signing-duration-snapshot -- \
|
||||
--apply --confirm=sync-logistics-arrival-signing-duration-snapshot \
|
||||
--expected-database-identity="<本地库身份>" \
|
||||
--expected-source-database-identity="<远端报表身份>"
|
||||
```
|
||||
|
||||
可调参数:`--snapshot-date=YYYY-MM-DD`、`--window-days`、`--chunk-days`(默认 7,按天分批抓取以控制内存)、`--batch-size`(默认 2000)、`--retention`、`--force`(当天批次已完成时强制重建)。
|
||||
|
||||
编排逻辑在 `src/server/services/logistics-arrival-signing-duration-snapshot.service.ts`:
|
||||
|
||||
1. 当天批次已 `COMPLETED` 且未 `--force` → 直接跳过;
|
||||
2. 标记 `RUNNING` 并清空当天旧行;
|
||||
3. 按 `chunk-days` 分片读远端并写入本地,同时记下真正抓到的最新签收时间 `dataThrough`;
|
||||
4. 全部成功才标记 `COMPLETED`(连同 `dataThrough`),随后按 `retention` 清理更早批次;任一分片失败则标记 `FAILED` 并保留错误信息,读路径会自动继续用上一批快照;
|
||||
5. 收尾关闭远端连接池(一次性任务必须关闭,否则进程挂在事件循环上)。
|
||||
|
||||
生产定时(systemd timer,默认北京时间 03:00):
|
||||
|
||||
```bash
|
||||
sudo bash scripts/install-logistics-arrival-signing-duration-snapshot-timer.sh
|
||||
```
|
||||
|
||||
首次安装会生成 `$BASE/shared/logistics-arrival-signing-duration-snapshot.env` 模板,需要填两个身份校验值(缺省为空会拒绝写入)。可用环境变量覆盖:`LOGISTICS_SNAPSHOT_CALENDAR`、`LOGISTICS_SNAPSHOT_WINDOW_DAYS`、`LOGISTICS_SNAPSHOT_RETENTION`。
|
||||
|
||||
## 读路径规则
|
||||
|
||||
`src/server/repositories/logistics-arrival-signing-duration.repository.ts` 的每个读方法都先判断快照是否可用:
|
||||
|
||||
- 取最近一批 `COMPLETED` 快照;请求区间**完全落在快照的实际覆盖范围**内才读快照(否则回落实时视图,避免返回不完整结果)。覆盖上限 = `min(windowTo - 1, dataThrough 当天)`:
|
||||
- `windowTo` 是跑批当天,当天还没签收完,所以只能信任到它前一天;
|
||||
- 再用批次记录的 `dataThrough`(该批真实最大签收时间)兜底,避免数据源滞后时把不完整的一天当成完整。
|
||||
- 例:一批 10-08 生成、数据只到 10-08 05:30 的快照,对 `10-01~10-08` 不再命中,看板/导出自动改用实时源,不会少算 10-08。
|
||||
- 命中快照时返回值带 `source: 'snapshot'` 与 `snapshotDate`,页面头部显示"快照数据 · 日期";未命中为 `source: 'live'`,显示原来的"数据更新 · 时间";
|
||||
- 快照表还没迁移或还没有任何批次时,`findLatestCompletedRun()` 返回 null(并吞掉异常),整条链路自动等价于改造前行为。
|
||||
|
||||
因此在以下情况仍会走实时数据源(较慢,但结果正确):请求区间超出窗口、任务当天尚未跑完或失败、本地还没生成过快照。
|
||||
|
||||
## 指标口径(整区间精确 vs 样本)
|
||||
|
||||
命中快照时,**整块看板都是整区间精确值**,与 2,000 条样本彻底解耦:
|
||||
|
||||
| 看板块 | 精确口径实现 |
|
||||
| --- | --- |
|
||||
| 经营总览(6 个 KPI) | `aggregateDashboard()` 一次扫描聚合 |
|
||||
| 履约环节拆解(5 环节平均/中位/P90) | 同上,`percentile_cont` 直接给出分位数 |
|
||||
| 发货→签收时效分布 | 同上,4 个区间计数 |
|
||||
| 付款→签收 / 付款→发货 多维经营分析(01–08) | `aggregatePayToSignAnalysis()` / `aggregatePayToSendAnalysis()`,按签收日期 + 采购/供应商/条码(`jsonb_array_elements` 展开 `productItems`)+ 物流公司/发货仓/平台/店铺分组 |
|
||||
| 发货仓 / 物流公司 超时排名 | `aggregateThresholdRankings()` |
|
||||
| 09 订单明细下钻 | `listDrilldownRows()`:按维度条件下钻,**列出全量匹配订单**并给出真实总数与页数 |
|
||||
|
||||
口径一致性由三点保证:筛选条件统一由 `snapshotFilterConditions()` 派生(Prisma 与原始 SQL 共用);比率/取整统一走 lib 的 `buildLogisticsArrivalSigningDurationOverview`、`buildPayToSignMetrics`、`buildPayToSendMetrics`;排序统一走 `comparePayToSignGroups` / `comparePayToSendGroups` / `compareLogisticsArrivalSigningDurationThresholdRows`。每个聚合块都用"把整区间明细喂给样本路径同样函数"的方式做过逐字段比对(MATCH)。
|
||||
|
||||
### 看板顺序与联动链路
|
||||
|
||||
### 履约环节拆解:目标值与看板宽度
|
||||
|
||||
### 经营总览:平均到货时长卡片与异常下钻
|
||||
|
||||
- 签收订单后面新增 **付款→签收平均到货时长** 卡片:主值是该环节平均时长,附行给出 **本月目标**(上月平均 × 98%)与 **目标达成率**,与履约环节拆解同一套口径。
|
||||
- **缺少关键时间 / 时间异常** 两张卡片可点击:点击后用 `{ kind: 'anomaly', value: 'missing' | 'invalid' }` 条件下钻,09 订单明细列出对应订单;再点一次取消。判定口径与总览一致——缺少关键时间 = 四个时间任一为空;时间异常 = 四个时间齐全但至少一个环节时长算不出来(时间顺序异常)。
|
||||
- **自动滚动只保留给经营总览的异常卡片**(卡片上写着"点击看明细"):点它们会把 09 订单明细滚到视口顶部(`detailBoardRef` + `scrollIntoView`,按"下钻签名"去重,不会在翻页时反复滚动)。**经营对象维度(平台/店铺/发货仓/物流/采购/供应商/商品/签收日期)的点选不再自动滚动**——那些看板是连着看的,一滚到底就没法接着看其他维度、还得手动往上拉;下钻筛选本身照常生效,09 明细就在原位置更新。
|
||||
- 为保证"卡片数字 = 点击后的明细条数",**命中快照时经营总览始终按整区间精确统计**(`aggregateDashboard(..., includeStages=false)`,只算总览不算分位数);超出精确上限的大区间里,只有履约环节拆解/时效分布/经营分析/超时排名退回样本,页面脚注会写明。
|
||||
|
||||
### 经营总览视觉层次与看板工具条
|
||||
|
||||
- 经营总览从"7 张卡一张网格"改为**两组网格**:5 张履约指标卡一排(`--sc-kpi-cols`,标准 5 / 紧凑 3 / 展开 2),2 张数据质量卡单独成排(`--sc-kpi-alert-cols`,标准 2 / 展开 1)。7 张卡在常见宽度下不再是 5+2、第二排右侧大片留白。
|
||||
- 卡片排版统一:标签 12px 用 `--sc-muted`,数值 24px 且 `font-variant-numeric: tabular-nums`,用 `margin-top: auto` 贴底、副标题保留 2 行最小高度(`min-height: 2.9em`)。同一行内标签换行不影响数值对齐,卡片等高、数字列对齐。
|
||||
- 绿色强调卡(付款→签收平均到货时长、付款→签收履约率)改为"浅绿底 + 左侧 2px 竖条"(`inset 2px 0 0`),不再整卡大面积着色;点击态的竖条与边框跟着状态色走。
|
||||
- 缺关键时间 / 时间异常改为横向"告警条":左侧图标 + 名称/说明,数值贴右,用低饱和暖色表达"异常"语义;点击下钻、`is-active` 选中态、`aria-pressed` 与 `aria-label`(含条数)都保留。
|
||||
- 修复看板工具条(导出 / 尺寸切换)样式不生效的根因:`BoardTools` 是本文件内的**独立组件**,styled-jsx 按组件注入作用域类,主组件里的 `.sc-logistics-icon-button` / `.sc-logistics-board-tools` / `.sc-logistics-board-size-label` 规则永远匹配不到它的 DOM,四个看板右上角一直是"裸图标"。现在样式放进 `BoardTools` 自己的 `<style jsx>`(深色排名表头用 `:global(.sc-logistics-ranking-board-header)` 适配),并删除主样式块里的死规则。
|
||||
|
||||
- 每个环节多出一列 **目标**:`本月目标 = 上月同环节平均时长 × 98%`(`LOGISTICS_ARRIVAL_SIGNING_DURATION_TARGET_IMPROVEMENT_RATIO`)。基准月取所选区间结束日期所在月的上一个月(跨年正确),基准值由 `aggregateStageAverages()` 用同一套筛选条件跑上个月区间得到;当前平均不高于目标时目标值标绿表示达标,鼠标悬停可看到"目标 = 上月平均 × 98%"的算式。
|
||||
- 目标后面还有 **目标达成率**:该环节时长不超过目标的订单占比。精确口径由 `aggregateStageTargetHits()` 按目标值统计命中订单数,样本口径由 `buildLogisticsArrivalSigningDurationStageAchievement()` 在样本上计算;无目标或无可计算订单时显示 `-`,鼠标悬停显示"命中 / 可计算"明细。环节行的平均/中位/P90/目标/达成率都只显示数值,单位(小时)只在表头语义里体现。
|
||||
- 环节行按 `128px / 弹性条 / 74 / 74 / 86 / 92` 收紧列宽,列表支持横向滚动,避免 P90、目标被截断。
|
||||
- 履约环节拆解与发货→签收时效分布之间加了**可拖动的分隔条**(`role="separator"`,支持左右方向键微调),比例默认 62% 并记在本地,可随时左右拖动调整两个看板的宽度。
|
||||
|
||||
经营对象问题排名各看板按"上级 → 下级"顺序排列,并逐级联动:
|
||||
|
||||
```
|
||||
01 签收日期维度 → 02 平台维度 → 03 店铺维度 → 04 发货仓维度 → 05 物流维度
|
||||
→ 06 采购维度 → 07 供应商维度 → 08 商品维度 → 09 订单明细
|
||||
```
|
||||
|
||||
点击任意一级对象,都会保留更上级条件、丢弃更下级条件,并把这条链路作为筛选条件重新取数:
|
||||
|
||||
- 整区间口径:链路以 `cascade`(JSON 数组,`LogisticsArrivalSigningDurationDrilldownFilter[]`)随 dashboard 请求下发,服务端在 `aggregatePayToSignAnalysis` / `aggregatePayToSendAnalysis` / `aggregateThresholdRankings` 的 WHERE 上追加 `cascadeSqlWhere`,因此下级看板与 09 明细都只展示对应数据;
|
||||
- 样本口径(实时源或超出精确上限):前端用 `logisticsArrivalSigningDurationRowMatchesCascade` 收窄样本后重算;
|
||||
- 店铺维度分组键是"平台 + 店铺",因此平台级联动能精确到该平台下的店铺;
|
||||
- 联动刷新是原位刷新(只有页面筛选变化才显示整块加载态),排名看板标题会显示当前链路与"联动加载中…"提示;
|
||||
- 经营总览、履约环节拆解、时效分布属于页级口径,不随联动链路变化。
|
||||
|
||||
### 经营对象的目标与目标达成率
|
||||
|
||||
02–08 经营对象表(平台 / 店铺 / 发货仓 / 物流 / 采购 / 供应商 / 商品)在原有指标后面追加 **目标** 与 **目标达成率** 两列;01 签收日期维度的「指标 × 日期」矩阵与总计不追加(按日算"上月同日目标"没有业务含义)。
|
||||
|
||||
- **目标 = 该对象上月同环节平均时长 × 98%**,达成率 = `100% − (平均 − 目标) / 目标 × 100%`,与履约环节拆解同一套口径,只是精确到每个经营对象——所以每个对象的目标值不同(例如 08 月各采购平均不同,9 月目标 52.7 / 56.5 / 60.5 …),达成的判断是"相对自己上个月是否改善 2%"。
|
||||
- 数值口径与展示一致:平均、目标都按 1 位小数取值后再算达成率,便于和表里看到的数字对账;没有上月基线的对象(新采购、新店铺、当月新条码)显示 `-`。
|
||||
- 取数:`aggregateObjectBaselines()`(快照仓库)在上月区间一次扫描算出 7 个维度 × 付款→签收 / 付款→发货 两个环节的平均时长,随 dashboard 载荷以 `objectBaselines` 下发;前端用 `withObjectAnalysisTargets()` 按分组键(`JSON.stringify(keyParts)`,与样本路径同一套键)合并,样本口径与整区间口径共用。
|
||||
- 这条聚合约 2–4 秒,因此按「快照日期 + 基准月 + 关键词/排除词」在服务端缓存 10 分钟(这些平均值只在每日快照重建时变化),并与看板其余查询并行执行;查询失败只让这两列显示 `-`,不会把整块看板拖回实时源。
|
||||
- 「经营对象问题排名」导出的 CSV 同步带上这两列。
|
||||
|
||||
### 看板分页
|
||||
|
||||
> 页面顶部的「经营管理 / 物流时效 + 看板标题 + 物流签收明细口径 + 明细条数/更新时间」页头已去掉(用户口径:无需展示,标签页标题已经说明是哪个看板);快照/缓存状态在数据源异常时的黄色提示条里仍会给出。
|
||||
|
||||
### 批量关键词查询
|
||||
|
||||
筛选区的「关键词」支持批量输入(与排除关键词同一套拆分规则):
|
||||
|
||||
- 按中文逗号 `,`、英文逗号 `,`、换行拆分,自动去空、去重,最多 100 个、单个最长 120 字符(原始输入上限 4000 字符);
|
||||
- **多个关键词之间是"或"**:命中任一字段即算匹配。整区间(快照)口径覆盖:内部单号、线上订单号、物流单号、物流公司、店铺、平台、发货仓、收货地址,以及快照时点固化的**条码、商品名称、供应商、采购**(`KEYWORD_FIELDS`)——所以能直接按采购/供应商/条码检索;
|
||||
- 走实时源时(区间超出快照窗口)只覆盖前 8 个报表字段,条码/采购/供应商这三类需要本地快照支持,这是两套口径的已知差异;
|
||||
- 解析规则由 `splitSearchKeywords()` 统一提供,服务端缓存键按关键词集合(排序后)计算,输入顺序不同不会重复缓存。
|
||||
- 输入框与其它筛选控件**等高**(34px 单行高度,多行时框内滚动),"已识别 N 个关键词"显示在标签行右侧并在空间不足时省略,不再把这一格撑成两行高。
|
||||
|
||||
筛选区的三个按钮各管一件事,点下去**立即按新条件查询**(互不重复):
|
||||
|
||||
| 按钮 | 签收日期 | 关键词 | 排除关键词 | 结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **重置** | 清空(输入框留空) | 清空 | 清空 | 没有任何筛选条件;空日期由服务端兜底为本月 1 号~昨日(`dateRangeCleared` 让输入框保持空白,不被兜底值回填) |
|
||||
| **恢复默认** | 回到本月 1 号~昨日(显式填回输入框) | 保留 | 恢复系统默认配置(全部勾选) | 默认口径 |
|
||||
| **清空排除** | 回到本月 1 号~昨日(显式填回输入框) | 保留 | 置空 | 默认区间 + 不排除任何关键词 |
|
||||
|
||||
三者过去都只是"改输入、等用户点查询"(重置只清日期与关键词、另两个只改排除词),结果看起来一样容易混;现在各自的语义、界面状态与结果都区分开了,按钮 hover 有对应说明,筛选栏底部也有一行图例。空日期提交时服务端仍按默认区间查询(跨天且当日快照未生成时可能落实时源,较慢但结果正确)。
|
||||
|
||||
**默认签收日期区间是「本月 1 号 ~ 昨日」**(由 `normalizeLogisticsArrivalSigningDurationFilters()` 生成):当日签收数据仍在滚动,且每日快照只覆盖到昨日,用"到昨日"作默认值可以让首屏直接命中快照(整区间精确 + 秒级),不再因为区间含当天而回落到实时源。每月 1 号时会自动退化为"昨日当天"(本月 1 号还没到昨日)。
|
||||
|
||||
筛选控件(签收日期、关键词输入框,以及排除关键词选择器的摘要)都是**受控并与服务端下发的筛选条件同步**:从菜单重新进入(无参数 URL)、重置后查询、浏览器前进/后退时,控件会刷成当前真正生效的条件。这一点很重要——否则会出现"数据已经是默认区间,输入框还停在上次条件"的错觉。
|
||||
|
||||
### 排除关键词选择器
|
||||
|
||||
筛选区的「排除关键词」是**按字段分组**的勾选式选择器(`ExcludeKeywordPicker.tsx`)。关键词只能配置在它要匹配的那个字段上,**不跨字段混用**——这是这一版最重要的口径修正。
|
||||
|
||||
**字段与匹配规则**
|
||||
|
||||
| 字段(配置分组) | 数据列 | 匹配方式 |
|
||||
| --- | --- | --- |
|
||||
| 发货仓 | `warehouseName` | 该字段值包含关键词(不区分大小写),命中即排除 |
|
||||
| 物流公司 | `logisticCompanyName` | 同上 |
|
||||
| 订单标签 | `orderLabels`(JSON 数组文本) | 同上 |
|
||||
| 买家留言 | `buyerMessage` | 同上 |
|
||||
| 卖家备注 | `remark` | 同上 |
|
||||
|
||||
配置与筛选都以「字段 → 关键词」存储(`LogisticsArrivalSigningDurationExcludeKeywords`)。SQL 侧对每个关键词只生成一条 `NOT (该字段 ILIKE '%词%')`;旧版"命中 5 个字段里任意一个就排除"的口径已废弃——那正是「物流」会连带命中订单标签里的 `商家指定物流`、`物流探查` 和买家留言里"物流单号"的根因。
|
||||
|
||||
**选择器交互**
|
||||
|
||||
- **按字段分组**:发货仓 / 物流公司 / 订单标签 / 买家留言 / 卖家备注,每组标题显示"共 N 项 · 已勾选 M 项",右侧索引条按字段跳转(字段没有候选词时不显示该组);
|
||||
- **默认全选**:候选词 = 该字段的默认排除词 ∪ 当前已排除词(旧链接里带的自定义词不会丢),进入看板时全部处于"排除"状态;取消勾选 = 本次不排除该项,确定后立即以当前表单条件重新查询(`requestSubmit()` 在受控值落到 DOM 后再触发);一个都不勾时以 `excludeKeywordsMode=clear` 提交,避免回落到默认排除词;
|
||||
- 选择器内含:搜索、`全部 / 已选项(n)` 页签、`全选 / 反选 / 清空`(作用于当前可见列表,全选带半选态)、`紧凑模式`(四列更密);
|
||||
- **新增候选词仅系统管理员可操作**:底部先选字段、再输入关键词(回车或点「添加」),新词并入**该字段**的默认排除词并立即落库(`PUT …/settings`,服务端 `assertCanManage` 同样校验管理员身份),提示"已保存到「物流公司」默认排除关键词:…";非管理员只看到"需要新增请联系系统管理员";
|
||||
- 页面原先独立的「默认排除关键词」设置卡片已删除,维护入口统一收敛到选择器;`清空排除`(不排除任何词)与 `恢复默认`(回到全部默认排除词)两个按钮保留,只改输入、点查询生效。
|
||||
|
||||
**编码与兼容**
|
||||
|
||||
- 筛选栏摘要按字段给数量(如"已排除 14 项:发货仓 3 · 物流公司 5 · 订单标签 2 · 买家留言 2 · 卖家备注 2"),URL 用 `excludeKeywords=字段:词,词;字段:词` 的紧凑写法;
|
||||
- **老链接的平铺写法按当前配置归位**:`excludeKeywords=词1,词2` 会被逐个对应到配置里它所属的字段(大小写不敏感,`bbff` 会归一到配置里的 `BBFF`),配置里已经没有的词说明它没有字段、直接忽略——**不会再把一个词摊到 5 个字段上**(那会让选择器每组都列出全部关键词)。只有当配置为空、任何词都对应不上时才退回旧口径,避免老链接突然变成"不排除任何词"。
|
||||
- 历史配置(数组)在 `normalizeExcludeKeywords()` 里仍按旧口径挂到全部 5 个字段(那是当年真实的匹配语义);但配置一旦以新结构保存,后续解析都会走"按字段归位"。
|
||||
|
||||
**当前初设配置**(2026-09-30 落库):发货仓=`自动发货仓 / 常州简尚家居有限公司 / 京东云仓`;物流公司=`京喜 / 物流 / 安能 / 自提 / 中铁`;订单标签=`配件 / 1688一件代发`;买家留言=`BBFF / SSDD`;卖家备注=`BBFF / SSDD`。2026-09-01~09-02 实测:不排除 26,296 行 → 按字段排除后 17,506 行(旧口径同样 14 个词是 16,552 行,多出的 954 行正是"标签里带物流、物流公司里没有"的那些)。
|
||||
|
||||
经营对象问题排名里的每个看板都有独立翻页,不再用"显示更多"或无限滚动,也不会因为对象太多被截断:
|
||||
|
||||
- 01 签收日期维度:按日期列翻页(默认 10 列/页,可切 10/20/50/100);
|
||||
- 02–08 采购/供应商/条码/物流公司/发货仓/平台/店铺:按对象行翻页(默认 20 行/页);
|
||||
- 发货仓 / 物流公司 超时排名:按对象行翻页(默认 20 行/页)。
|
||||
- 09 订单明细:翻页控件就在看板内部(不再挂在页面底部);未下钻时按 URL 的 `page/pageSize` 走服务端分页(每页 25/50/100/200,命中快照时显示真实总数"共 N 条筛选结果"),下钻时用本地分页翻全量匹配明细。
|
||||
|
||||
分页条统一由 `BoardPagination.tsx` 渲染(含"共 N 条 · 第 x / y 页"、上/下一页与每页条数),行集合变化(例如切换采购后重新筛选供应商)时自动回到第 1 页。
|
||||
|
||||
**每页条数切换的坑(已修)**:下拉框原先在 `onChange` 里同时调了 `onPageSizeChange(新值)` 和 `onPageChange(1)`。服务端分页(09 订单明细未下钻时)这两次都会触发 `router.push`,而后一次带着**旧的** `pageSize` 构造 URL 并覆盖前一次,表现为"订单明细每页条数选了没反应、又跳回 50"。现在 `BoardPagination` 只通知"每页条数变了",回到第 1 页由调用方负责:服务端分页用 `href(1, { ...filters, pageSize })`,本地分页(各经营对象表、发货仓/物流公司排名、下钻后的 09 明细)在 `setState` 里带 `page: 1`。
|
||||
|
||||
店铺维度表在"店铺"列前额外展示"平台"列:点击平台即把该表筛成该平台的店铺(标题变成"店铺维度 · 天猫",再次点击取消),点击店铺名仍走 09 明细下钻。店铺分组的业务键是"平台 + 店铺",避免同名店铺跨平台互相覆盖;平台列在样本路径(JS 归组)与整区间路径(SQL `GROUPING SETS ((platform, shop))`)都做了同样的处理,并逐字段比对过一致性。
|
||||
|
||||
### 规模上限与样本回落
|
||||
|
||||
SQL 聚合成本与区间行数近似线性(约 5 万行/秒)。当**区间原始行数超过 `LOGISTICS_ARRIVAL_SIGNING_DURATION_EXACT_SCOPE_MAX_ROWS`(11 万条,约 11 天)**时,`getDashboard` 会退回区间内均匀抽取的 2,000 条样本(`listDashboardSampleRows`,5 层并行、按签收时间分层,不再是"取最近一段"),并在底部注释写明原因与"缩小签收日期范围即可得到精确值"。
|
||||
|
||||
返回值上的 `dataScope`(`exact` / `sample`)与 `dataScopeReason`(`live-source` / `range-too-large`)驱动卡片副标题、底部注释与 CSV 导出的口径说明。
|
||||
|
||||
实测(663k 行快照,含 13 个默认排除关键词):
|
||||
|
||||
| 区间 | 原始行数 | 口径 | 首屏耗时 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 天 | 约 1.3 万 | 整区间精确 | 0.55s |
|
||||
| 2 天 | 约 2.6 万 | 整区间精确 | 1.7s |
|
||||
| 5 天 | 约 6.5 万 | 整区间精确 | 3.1s |
|
||||
| 本月 | 约 30 万 | 样本(超上限) | 3.8–5.0s |
|
||||
|
||||
服务端 60s 内重复访问直接命中缓存。要把大区间也做成亚秒精确,方向是在快照写入时就把 4 个时间戳、5 个环节时长落成列,并把多维指标的计数改为位掩码 `GROUP BY`(一次扫描产出全部桶计数),现有文档结论不变、只替换实现。
|
||||
|
||||
### 查询性能优化(2026-10)
|
||||
|
||||
上线后"每次查询生成太慢"的根因有三个,都在快照读路径上:
|
||||
|
||||
1. **整表顺序扫描**:聚合查询原先 `SELECT` 全列(`rowKey`、`productItems`、十几个文本列),
|
||||
Postgres 只能对 900MB 的堆表做 `Seq Scan`(28 天区间实测 `read=106409` 页、6.3 秒)。
|
||||
现在按用途收窄列集合——`FILTERED_TIMELINE_SELECT`(只取 4 个时间戳)用于整区间总览/环节目标,
|
||||
`FILTERED_DIMENSION_SELECT`(时间戳 + 4 个维度列)用于签收日期/平台/店铺/发货仓/物流/超时排名——
|
||||
并新增覆盖索引 `LogisticsArrivalSigningDurationSnapshot_cover_idx`
|
||||
(`(snapshotDate, signTime) INCLUDE (payTime, sendTime, fetchTime, warehouseName, platformName, shopName, logisticCompanyName)`),
|
||||
扫描变成 **Index Only Scan**(同样的 28 天区间 26ms、`Heap Fetches: 0`)。
|
||||
2. **有序集聚合把整行拖进排序**:`percentile_cont(0.5/0.9) WITHIN GROUP` 原先和十几个计数写在同一条语句里,
|
||||
输入行 14 列宽,PG 18 会把它整行送进排序(两个分位数单独就占 4.3 秒,环节分位数叠在一起甚至到分钟级)。
|
||||
现在分位数**按列拆成独立语句**、只投影一个浮点列,并与计数语句并行下发(`Promise.all`)。
|
||||
3. **CTE 表达式被重复内联**:`measured`/`durations` 的 `CASE + EXTRACT` 会在每一处引用被重新计算,
|
||||
同一条聚合里重复十几次。改为 `AS MATERIALIZED` 后只算一次(计数语句从 2.3s 降到 ~0.5s)。
|
||||
|
||||
另外把 **环节目标(上月平均 × 98%)** 也接入 10 分钟内存缓存(此前每次刷新都重跑上月聚合)。
|
||||
|
||||
优化前后(本机 Postgres 18、快照 648,689 行、冷启动、无缓存):
|
||||
|
||||
| 场景 | 优化前 | 优化后 |
|
||||
| --- | --- | --- |
|
||||
| 仓库层 `getDashboard`(28 天,样本口径) | 7.3s | **2.3s** |
|
||||
| 仓库层 `getDashboard`(10 天) | 4.4s | **0.4s** |
|
||||
| 仓库层 `getDashboard`(2 天,精确口径) | 1.7s | **0.3s** |
|
||||
| 浏览器首屏出数(冷启动,含 5.6MB 载荷 + 渲染) | ~7–10s | **2.7s** |
|
||||
|
||||
正确性用 2 天区间做过逐项对账:SQL 聚合与"取全量明细在 JS 里现算"在订单数、平均/中位/P90、
|
||||
异常计数、达成率、时效分布、5 个环节的 可计算单量/平均/中位/P90 上完全一致。
|
||||
|
||||
时间戳在 SQL 里用 `NULLIF(BTRIM(col),'')::timestamp` 直接转型:快照写入的就是远端 `CONVERT(varchar(19), …, 120)` 结果,格式固定;早期版本用正则逐行校验,整区间聚合约慢一个数量级。
|
||||
|
||||
## 运维排查
|
||||
|
||||
```sql
|
||||
-- 批次状态
|
||||
SELECT "snapshotDate", status, "windowFrom", "windowTo", "rowCount", message, "finishedAt"
|
||||
FROM "LogisticsArrivalSigningDurationSnapshotRun" ORDER BY "snapshotDate" DESC;
|
||||
|
||||
-- 当天行数
|
||||
SELECT count(*) FROM "LogisticsArrivalSigningDurationSnapshot" WHERE "snapshotDate" = CURRENT_DATE;
|
||||
```
|
||||
|
||||
- 页面长时间显示"数据更新"而不是"快照数据" → 先看批次是不是 `FAILED`,或窗口是否覆盖了页面的签收日期区间。
|
||||
- 批次 `RUNNING` 卡住(进程被杀)→ 该批次不会发布;直接重跑或加 `--force` 即可(重跑会先清空当天行)。
|
||||
- 需要重建历史窗口 → `--window-days=` 调大 + `--force`。
|
||||
|
||||
## 部署步骤(生产)
|
||||
|
||||
1. 部署新版本代码;
|
||||
2. 执行迁移 `prisma/migrations/20260928120000_add_logistics_arrival_signing_duration_snapshot`(需按生产迁移流程授权、先备份);
|
||||
3. 跑一次手工同步生成第一批快照(`--apply`),确认批次为 `COMPLETED`、行数与窗口符合预期;
|
||||
4. 安装 timer 并核对 `systemctl list-timers`;
|
||||
5. 打开看板确认头部显示"快照数据 · 日期"。
|
||||
|
||||
注意:Prisma Client 重新生成过,运行中的 dev/prod 进程需要重启才能拿到新模型。
|
||||
207
furniture-3d-system/docs/设计规范/苹果风格视觉标准.md
Normal file
207
furniture-3d-system/docs/设计规范/苹果风格视觉标准.md
Normal file
@ -0,0 +1,207 @@
|
||||
# 苹果风格视觉设计标准 (Apple-Style Visual Design Standards)
|
||||
|
||||
> **版本**: 2.0 (2026-01-29)
|
||||
> **状态**: 补充参考标准;当前新平台第一视觉标准以 `白曜石工坊统一视觉标准.md` 为准
|
||||
> **优先级**: 如本文件与白曜石工坊标准冲突,优先执行白曜石工坊标准
|
||||
> **目标**: 打造优雅、统一、沉浸式的用户体验,完全去除"模板化"和"AI味",接近苹果官网的视觉水准。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [核心设计哲学](#1-核心设计哲学)
|
||||
2. [详细视觉规范](#2-详细视觉规范)
|
||||
* [色彩系统](#21-色彩系统)
|
||||
* [排版系统](#22-排版系统)
|
||||
* [布局与间距](#23-布局与间距)
|
||||
* [材质与光影](#24-材质与光影)
|
||||
3. [交互与动画](#3-交互与动画)
|
||||
4. [去AI味专项指南](#4-去ai味专项指南)
|
||||
5. [技术架构与文件结构](#5-技术架构与文件结构)
|
||||
6. [组件开发规范](#6-组件开发规范)
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心设计哲学
|
||||
|
||||
### 1.1 内容至上 (Content is King)
|
||||
* **原则**:界面不仅是容器,更是内容的舞台。UI 应该"退后",让 3D 模型、产品图片和关键数据"向前"。
|
||||
* **执行**:
|
||||
* 背景色主要使用白色或极淡的灰色;当前产品不再提供深浅色主题切换。
|
||||
* **拒绝**无意义的装饰性边框、分割线和阴影。只有在区分层级必要时才使用。
|
||||
* **拒绝**均等分割的网格布局("AI味"典型特征)。使用非对称布局(如:左侧 2/3 展示 3D 模型,右侧 1/3 展示控制面板)。
|
||||
|
||||
### 1.2 物理感与材质 (Materiality & Physics)
|
||||
* **原则**:数字界面应模拟真实世界的物理属性。
|
||||
* **执行**:
|
||||
* **Liquid Glass (毛玻璃)**:仅在覆盖内容时使用(如导航栏、侧边栏、浮动面板)。标准:`backdrop-filter: blur(20px) saturate(180%)`。
|
||||
* **Spring Animation (弹簧动画)**:交互元素(按钮按压、弹窗出现)必须使用弹簧物理模拟,而不是简单的线性过渡。
|
||||
* **光影**:光源应统一来自正上方。阴影应柔和、弥散,模拟环境光遮蔽 (Ambient Occlusion)。
|
||||
|
||||
### 1.3 优雅的排版 (Elegant Typography)
|
||||
* **原则**:排版是信息层级的核心。
|
||||
* **执行**:
|
||||
* **动态字间距 (Dynamic Tracking)**:大标题收紧 (-0.4px),正文标准,小字加宽 (+0.06px)。
|
||||
* **层次分明**:使用字重 (Weight) 和颜色 (Color) 区分层级,而不是单纯靠字号。
|
||||
|
||||
---
|
||||
|
||||
## 2. 详细视觉规范
|
||||
|
||||
### 2.1 色彩系统
|
||||
|
||||
**设计令牌位置**: `src/lib/design-tokens/colors.ts`
|
||||
|
||||
不要使用纯灰 (`#808080`),总是混入微量的蓝色或暖色调。
|
||||
|
||||
| 用途 | Light Mode | Dark Mode | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **背景** | `#FFFFFF` | `#000000` | 纯净背景,最大化对比度 |
|
||||
| **次级背景** | `#F5F5F7` | `#1C1C1E` | 用于分组区块,**比系统灰(#F2F2F7)更淡、更优雅** |
|
||||
| **主文本** | `#1D1D1F` | `#F5F5F7` | 接近纯黑但不是纯黑 (Off-black) |
|
||||
| **次文本** | `#86868b` | `#86868b` | 经典的苹果灰,用于说明文字 |
|
||||
| **强调色** | `#008D74` | `#00B896` | 品牌绿,仅用于链接和主按钮 |
|
||||
| **分割线** | `rgba(0,0,0,0.1)` | `rgba(255,255,255,0.1)` | 极细,极淡 |
|
||||
|
||||
### 2.2 排版系统
|
||||
|
||||
**设计令牌位置**: `src/lib/design-tokens/typography.ts`
|
||||
|
||||
我们使用**合法的开源/系统字体栈**,严格避免版权风险:
|
||||
* **西文**: `-apple-system`, `BlinkMacSystemFont`, `Inter`, `Segoe UI`
|
||||
* **中文**: `-apple-system`, `Noto Sans CJK SC`, `Source Han Sans SC` (思源黑体), `Microsoft YaHei`
|
||||
* **代码**: `JetBrains Mono`, `Fira Code`
|
||||
|
||||
| 样式 | 字号 (px) | 行高 (px) | 字间距 (px) | 用途 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Large Title** | 34 | 41 | -0.4 | 页面主标题 |
|
||||
| **Title 1** | 28 | 34 | -0.36 | 章节标题 |
|
||||
| **Title 2** | 22 | 28 | -0.26 | 子章节标题 |
|
||||
| **Headline** | 17 | 22 | -0.41 | 强调文本 (Semibold) |
|
||||
| **Body** | 17 | 25 | -0.41 | 正文内容 |
|
||||
| **Caption 1** | 12 | 16 | 0 | 说明文字 |
|
||||
|
||||
### 2.3 布局与间距
|
||||
|
||||
**设计令牌位置**: `src/lib/design-tokens/spacing.ts`
|
||||
|
||||
* **基础网格**: 4px
|
||||
* **大留白原则**:
|
||||
* 模块间距至少 `48px` 或 `64px`。
|
||||
* 卡片内部 Padding 至少 `24px`。
|
||||
* **流体布局**:
|
||||
* 容器宽度随视口变化,最大宽度限制 (Max-width: 1440px)。
|
||||
* **全宽突破**: Hero Banner 等关键元素应占据全宽。
|
||||
|
||||
### 2.4 材质与光影
|
||||
|
||||
* **Liquid Glass**:
|
||||
* Light: `rgba(255, 255, 255, 0.72)` + Blur 20px
|
||||
* Dark: `rgba(22, 22, 23, 0.72)` + Blur 20px
|
||||
* **阴影 (Shadows)**:
|
||||
* Subtle: `0 2px 8px rgba(0, 0, 0, 0.04)` (静态元素)
|
||||
* Standard: `0 4px 12px rgba(0, 0, 0, 0.08)` (悬浮/交互)
|
||||
* Elevated: `0 8px 24px rgba(0, 0, 0, 0.12)` (模态框)
|
||||
|
||||
---
|
||||
|
||||
## 3. 交互与动画
|
||||
|
||||
**设计令牌位置**: `src/lib/design-tokens/animations.ts`
|
||||
|
||||
所有交互必须有物理反馈。
|
||||
|
||||
* **按钮按压**: Scale 0.98, Duration 100ms
|
||||
* **微交互 (Hover)**: Scale 1.02, Duration 200ms
|
||||
* **页面过渡**: Duration 500ms, Curve `cubic-bezier(0.16, 1, 0.3, 1)`
|
||||
* **Spring 参数**: 使用 `spring(mass: 1, stiffness: 100, damping: 15)` 模拟真实回弹。
|
||||
|
||||
---
|
||||
|
||||
## 4. 去AI味专项指南
|
||||
|
||||
"AI味" = 死板、重复、缺乏人情味、过度依赖默认组件。
|
||||
|
||||
### 4.1 拒绝"Lorem Ipsum"
|
||||
* ❌ "Product Name 1", "This is a description."
|
||||
* ✅ "Eames Lounge Chair", "由模压胶合板和皮革制成,经典的设计符号。"
|
||||
* **要求**:所有占位文本必须是**真实场景数据**。
|
||||
|
||||
### 4.2 拒绝"均等分布"
|
||||
* ❌ 4个功能点,每个占 25% 宽度,图标+标题+描述,完全一样。
|
||||
* ✅ 1个核心功能占 60% 宽度配大图,其余 3 个功能以列表形式在侧边展示。打破对称。
|
||||
|
||||
### 4.3 拒绝"默认图标"
|
||||
* ❌ 使用实心块状图标。
|
||||
* ✅ 优先使用文字。如果必须用图标,使用细线条风格 (Stroke width 1.5px-2px)。
|
||||
|
||||
### 4.4 拒绝"彩虹渐变"
|
||||
* ❌ 高饱和度彩虹渐变背景。
|
||||
* ✅ 纯色、微渐变或高质量实拍图/3D渲染图。
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术架构与文件结构
|
||||
|
||||
本项目基于 **Next.js 16 + Tailwind CSS + React 19** 构建。
|
||||
|
||||
### 5.1 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ └── design-tokens/ # 核心视觉配置
|
||||
│ ├── typography.ts # 字体定义
|
||||
│ ├── colors.ts # 颜色定义
|
||||
│ ├── spacing.ts # 间距定义
|
||||
│ └── animations.ts # 动画定义
|
||||
├── components/
|
||||
│ └── ui/ # 基础原子组件
|
||||
│ ├── Button.tsx # 遵循规范的按钮
|
||||
│ ├── Card.tsx # 遵循规范的卡片
|
||||
│ ├── Typography.tsx # 文本组件封装
|
||||
│ └── ...
|
||||
└── app/
|
||||
└── globals.css # CSS 变量映射 (自动从 tokens 生成)
|
||||
```
|
||||
|
||||
### 5.2 迁移指南
|
||||
|
||||
如果你在代码中看到旧的 CSS 类(如 `text-gray-500`),请替换为语义化的设计令牌类:
|
||||
* ❌ `text-gray-500` -> ✅ `text-(--color-label-secondary)` (或封装好的 Tailwind 类)
|
||||
* ❌ `bg-white` -> ✅ `bg-(--color-background-primary)`
|
||||
* ❌ `shadow-lg` -> ✅ `shadow-apple-lg`
|
||||
|
||||
---
|
||||
|
||||
## 6. 组件开发规范
|
||||
|
||||
### 6.1 Button
|
||||
* **形状**: 高度 36px/44px,Pill shape (999px) 或 Rounded (8px)。
|
||||
* **质感**: Primary (纯色无边框), Secondary (浅灰背景)。禁止复杂渐变。
|
||||
|
||||
### 6.2 Card
|
||||
* **圆角**: 统一 `20px` 或 `24px`。
|
||||
* **边框**: Light Mode 下通常无边框或极淡 `1px` 边框。
|
||||
* **内容**: 内部 Padding 至少 `24px`。
|
||||
|
||||
### 6.3 Input
|
||||
* **样式**: 移除默认边框,使用浅灰背景 (`#F5F5F7`),聚焦时出现 Focus Ring。
|
||||
* **高度**: 至少 `44px`。
|
||||
|
||||
### 6.4 Slider
|
||||
* **组件来源**: 统一使用 `src/components/ui/Slider.tsx`,禁止直接使用原生 `input[type="range"]`。
|
||||
* **轨道样式**: 高度 `6px`,圆角 `999px`,未填充色 `#D4DCE6`,填充色使用品牌色 `#008D74`(深色场景可切换 `#00B896`)。
|
||||
* **滑块样式**: 尺寸 `16px`,白色描边 `2px`,投影 `0 1px 3px rgba(0, 0, 0, 0.25)`。
|
||||
* **状态规范**: 禁用态降低不透明度并禁用交互光效;拖动态允许轻微放大(建议 `scale(1.08)`)。
|
||||
* **可访问性**: 必须提供 `aria-label`,并保持键盘可调节。
|
||||
|
||||
### 6.5 响应式
|
||||
* 使用 `useBreakpoint` 或 Tailwind 的响应式前缀 (`md:`, `lg:`)。
|
||||
* 支持 iPhone 刘海屏安全区域 (`safe-area-inset`).
|
||||
|
||||
---
|
||||
*遵循此标准,我们将构建出一个不仅好用,而且令人愉悦的家具展示系统。*
|
||||
|
||||
|
||||
|
||||
@ -1,124 +0,0 @@
|
||||
# 限价与破价判定规则(业务确认版)
|
||||
|
||||
> 适用范围:限价管理 · 破价订单审核(`/supply-chain/management/custom-table` → 破价订单审核)
|
||||
> 本文件为业务口径的唯一来源;规则变更请改本文件并注明生效时间。
|
||||
|
||||
## 一、限价与判定口径
|
||||
|
||||
1. **判定基准**:以「商家实收」为准;`商家实收 < 限价 × 数量` 即判定破价。
|
||||
2. **实付款与平台承担**:实付款 + 平台承担(红包 / 消费券 / 平台券)= 商家实收;凭证必须同时包含「实付款」和「数量」。
|
||||
3. **出资方字典**
|
||||
- 商家承担:店铺优惠(官方立减、单品直降、店铺券)、政府补贴
|
||||
- 平台承担:红包、消费券、平台券、平台优惠、新品礼金
|
||||
- 买家承担:淘金币
|
||||
- 待定:首单礼金(等与运营确认后补录)
|
||||
4. **限价维度**:按**货号**限价——同一货号下所有规格/条码共用同一套限价。
|
||||
5. **限价时间版本**:按订单付款时点生效的限价判定(限价调整前后分别取当时版本),不做"用最新限价回算历史单"。
|
||||
6. **成本变动规则(限价维护侧)**:成本价上涨 5% 以内不动限价;成本价下调不变限价。
|
||||
7. **非负责人限价**:2026-10-07 00:00 前的 ID 价格不判定破价(此后按规则判定)。
|
||||
8. **老链接分界**:上架时间早于 2026-10-07 00:00 的链接(老链接)破价申诉通过;之后的按限价流程结束日期判定(上架时间需早于流程结束日期),并核对店铺款式编码 / 商品编码。
|
||||
9. **负责人触发规则**:非运营发起的流程,前两个月全公司运营同价(不归属个人);两个月后按销量触发负责人(销量高者为负责人)。
|
||||
10. **数据快照**:破价单在生成时保存金额快照;退款/售后状态单独记录,退款不等于免责(仍需判断是否已调价)。
|
||||
11. **离职人员**:订单负责人已离职 → 自动标记「人员已离职」,不判定、不计乐捐。
|
||||
12. **是否有检测报告**:取素材平台「检测报告」接口(`/api/external/certificate-reports`,按 **SPU** 精确匹配,只认有报告文件的记录),进程内缓存 10 分钟;
|
||||
接口未配置或读取失败时该列显示为空、筛选候选值为空。配置项:`MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL/API_KEY`,
|
||||
不填则复用素材平台 `MATERIAL_LIBRARY_WHITE_BACKGROUND_API_*`。
|
||||
13. **手工换货单**:聚水潭订单明细出现「被替换行(`item_status=Replaced`、数量归 0)」+「换入行(`item_ext_data` 含 `item_source=手工换货/手工添加`)」时按换货单处理:
|
||||
- 判定按**换出行原编码**的限价执行(客户在平台上购买的那个编码);
|
||||
- 成交数量与商家实收取**换入行**(换货不改变订单成交金额);换入行金额抓不到时回退订单主表商家实收 → 订单主表买家实付,都取不到才按 0(由运营申诉说明);
|
||||
- 换货行配对成功后按原编码生成一条,不再按换入编码单独生成,避免同一订单重复生成两条破价单;
|
||||
- 配对优先按 `sku_uuid` 精确匹配,剩余行仅在一对一时顺序配对;复杂结构(多对多)暂不强行配对,相关行按各自编码现状处理。
|
||||
|
||||
## 二、申诉与审核
|
||||
|
||||
1. **申诉类型**(4 组)
|
||||
- 链接时间:老链接,上架时间早于限价流程(凭证须含链接 ID + 创建时间)/ 京喜换主体 · 老店转新店
|
||||
- 优惠 / 活动:平台优惠 · 红包 · 券(非商家承担)/ 参与秒杀 · 百补 · 大促等平台活动(凭证须含活动开始与截止时间,且活动价未破价)/ 拼多多多件折扣(99折)
|
||||
- 流程申请:已走新品限价及限价调整(必填流程号,系统到「新品限价及限价调整」流程校验,查不到不允许提交)
|
||||
- 其他原因(须在备注中详细说明)
|
||||
2. **申诉范围**:链接时间 / 流程申请类支持**链接级一次性申诉**(默认勾选,覆盖该链接下全部破价订单,后续新单自动继承);优惠 / 活动类按订单申诉。
|
||||
3. **审核要点仅审核人可见**;申诉人只看到凭证要求与示例图。
|
||||
4. **凭证校验**:必填校验 + 识别提示(识别结果仅提示,人工一键打回);后续增加图片查重(hash / 相似度)与关键字段(订单号、链接 ID)一致性校验。
|
||||
5. **乐捐**:驳回计 ¥10/单(离职不计);配套审计链(数据快照 + 申诉材料 + 审核记录),扣款执行交财务确认,保留异议通道。
|
||||
6. **举证期限**:待与运营确认后设置(避免事后无法追溯凭证)。
|
||||
|
||||
## 三、权限(由管理员统一管理)
|
||||
|
||||
路径:用户管理后台 → 权限配置 → 经营管理。限价管理相关的查看 / 导出 / 申诉都由管理员在这里开通。
|
||||
|
||||
| 能力 | 权限节点 | 动作 |
|
||||
| --- | --- | --- |
|
||||
| 页面与数据查看(限价明细、破价订单审核) | 限价管理 | 可见 |
|
||||
| 导出(导出明细 / 导出明细(含图片)/ 破价订单导出 / 乐捐明细) | 限价管理 | 下载 |
|
||||
| 申诉 · 审核 · 生成 · 导入 | 限价管理 | 操作 |
|
||||
| 规则配置(页面可见 + 保存参数) | 限价管理 · 规则配置 | 可见 / 操作 |
|
||||
|
||||
- 没有「下载」权限时页面不展示导出入口,服务端导出接口同样鉴权(下载中心任务按「限价管理 · 下载」校验)。
|
||||
- 没有「操作」权限时:不显示申诉/审核按钮,服务端也会拒绝申诉、审核、生成、导入。
|
||||
- 申诉保留一条兜底:破价单的负责人本人,即使没有「操作」权限,也可以对本人负责的订单发起申诉(审核仍需「操作」)。
|
||||
- 没有「规则配置」权限时,规则配置页签不显示、接口不可调用。
|
||||
|
||||
## 四、生成与运行
|
||||
|
||||
1. **按天增量**:限价逻辑上线后,破价订单**按天增量生成**(每天只生成最新一天的破价单并入库),**历史数据不再重复回算**;破价订单审核页每天展示最新破价单。
|
||||
2. **数据来源**:聚水潭报表库(`jst_Orders` + `jst_OrderItem` / 天猫奇门 `jst_qmcust_orderlist` + `_item`),取 `seller_income_amount`、`buyer_paid_amount`、`qty`、`shop_i_id`、`shop_sku_id`、`pay_date`、退款状态,以及换货识别字段 `item_status`、`item_ext_data`(`item_source`、`sku_uuid`)。报表库查询较慢,逐日增量任务建议按天分片拉取。
|
||||
3. **限价来源**:钉钉流程(仅取**已完结**),共 4 个模板,同步脚本 `scripts/sync-limit-workflows.mjs`:
|
||||
- 「新品限价及限价调整」→ 货号取「货号(非商品编码)」,限价取「期望日常限价」;
|
||||
其中的「调整限价明细表」子表(申请类型=调整原货号限价)只填「调整后限价」,该列同样作为限价取值
|
||||
(2026-10-16 修复,此前这类调价会被漏掉);
|
||||
- 「新品限价流程」→ 明细取系统产品限价页面(按流程链接 SPU 关联)。页面数据来自正式库
|
||||
`external.product_catalog_wide_v1`(限价字段:`expected_daily_limit_price` / `non_spu_owner_limit_price`),
|
||||
本地库该视图为空时退回本地 `IntakeNonIntegratedProduct(LimitRow)`;
|
||||
- 「各中心特殊限价需求申请」→ 货号取「货号」,限价取「最低售价」;
|
||||
- 「GB特殊限价需求申请」→ 货号取「货号」,限价取「期望日常限价」(2026-10-16 纳入)。
|
||||
- 「产品限价页面」已确认(CONFIRMED)的限价全量纳入(2026-10-16):来源正式库
|
||||
`external.product_catalog_wide_v1`,给没有限价流程的货号兜底(如 CY012801=268.6);
|
||||
同货号与流程限价冲突时**以流程为准**,页面价只在流程没覆盖时生效。
|
||||
- 「限价期初数据」(业务整理的存量限价 Excel,2026-10-16 纳入):放在期初数据目录
|
||||
(`LIMIT_SYNC_INITIAL_DIR`,默认 `<app>/期初数据`;目录为空时用仓库自带
|
||||
`scripts/data/limit-sync/期初数据/限价期初数据*.xlsx`),取目录里最新一份,
|
||||
按「货号 + 负责人限价 + 更新日期」写入,作为**最低优先级**兜底(如 MKHDNZ0409260600468=279)。
|
||||
|
||||
优先级:**限价流程 > 产品限价页面(已确认)> 限价期初数据**;低优先级只在货号没有更高优先级限价时生效。
|
||||
**例外(2026-10-16 确认)**:期初数据的「更新日期」**晚于**流程完成时间时,以期初数据为准(谁新用谁);
|
||||
期初比流程旧则维持上面的优先级。
|
||||
**非负责人限价一律按最新口径计算**(2026-10-16 确认):负责人限价 × 1.05,且不低于 +10 元;
|
||||
期初数据表 / 产品限价页面里的历史「非负责人限价」(倍数 1%~15% 不统一)不采用,
|
||||
限价明细页、导出文件、破价判定三处都用同一公式。
|
||||
写入规则:同货号多流程取**审核通过时间最新**的一条;限价为 0 或空的明细剔除;「申请与SPU负责人同价 / 活动限价」不覆盖负责人限价,只作为价格授权;流程「货号」列若填的是商品编码,用商品编码匹配产品档案换算为货号;无货号时同样用商品编码匹配。页面限价的时间按**北京时间墙钟**存(与流程完成时间一致)。
|
||||
|
||||
4. **覆盖率**:暂不做未匹配清单与告警(后续优化)。
|
||||
|
||||
5. **正式站同步(服务器端)**:`scripts/sync-limit-workflows.mjs` 已改成服务器可运行,连接信息全部取
|
||||
服务器 `shared/.env` 里的既有变量(`DATABASE_URL` / `JST_REPORT_DB_*` / `PROD_ARCHIVE_DB_*`),
|
||||
不再依赖 Mac 本地路径:
|
||||
|
||||
```bash
|
||||
# 手动跑一次(正式站目录下,root 或能读 shared/.env 的账号)
|
||||
cd /opt/furniture-3d-system-prod/current/furniture-3d-system
|
||||
sudo -E bash -lc 'set -a; . /opt/furniture-3d-system-prod/shared/.env; set +a; \
|
||||
LIMIT_SYNC_OUT_DIR=/opt/furniture-3d-system-prod/shared/limit-workflow-sync/导出结果 \
|
||||
LIMIT_SYNC_INITIAL_DIR=/opt/furniture-3d-system-prod/shared/limit-workflow-sync/期初数据 \
|
||||
node scripts/run-limit-workflow-sync-production.cjs'
|
||||
|
||||
# 安装每日定时器(systemd,与营销日销同一套样板):每天北京时间 07:30
|
||||
sudo bash scripts/install-limit-workflow-sync-timer.sh
|
||||
systemctl list-timers --all 'furniture-limit-workflow-sync*' --no-pager
|
||||
journalctl -u furniture-limit-workflow-sync.service -n 200 --no-pager
|
||||
```
|
||||
|
||||
- 写库是**全量重建 + 单事务**:成功才提交;失败自动回滚,正式库保留上一版限价与授权。
|
||||
- 输出目录 `LIMIT_SYNC_OUT_DIR`(默认 `<app>/导出结果`)里的 `限价流程_完整明细表.xlsx` 每日覆盖,
|
||||
旧版本进 `历史版本/`,默认保留最近 20 份(`LIMIT_SYNC_HISTORY_KEEP`)。
|
||||
- `--skip-excel` / `LIMIT_SYNC_SKIP_EXCEL=1` 可只写库不导 Excel。
|
||||
- 期初数据更新:把新的 `限价期初数据*.xlsx` 放进 `shared/limit-workflow-sync/期初数据/` 即可,下次同步自动取最新。
|
||||
- `SupplyChainBreachRuleConfig` 不需要灌数:接口读取时用代码内置默认值,首次在「规则配置」保存时落库。
|
||||
|
||||
## 五、待办
|
||||
|
||||
- [ ] 限价流程数据接入 + 筛选逻辑(生效时间版本、货号维度)
|
||||
- [ ] 非负责人限价 10-07 规则、负责人触发规则(前两个月同价 / 两个月后按销量)落到判定引擎
|
||||
- [ ] 按天增量任务(cron / worker)与失败告警
|
||||
- [ ] 凭证查重(hash / 相似度)、关键字段一致性校验
|
||||
- [ ] 举证期限、乐捐财务对接流程
|
||||
- [ ] 首单礼金 / 新品礼金出资方确认后补录出资方字典
|
||||
@ -74,7 +74,6 @@ const contentSecurityPolicy = [
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
`frame-src 'self' ${lodopScriptSources.join(' ')}`,
|
||||
"form-action 'self'",
|
||||
"img-src 'self' data: blob: http: https:",
|
||||
"font-src 'self' data:",
|
||||
@ -240,7 +239,7 @@ const nextConfig = {
|
||||
},
|
||||
|
||||
// 服务器外部包配置(Next.js 16新配置)
|
||||
serverExternalPackages: ['sharp', 'three', 'gsap', '@huggingface/transformers', 'onnxruntime-node', 'mssql'],
|
||||
serverExternalPackages: ['sharp', 'three', 'gsap', '@huggingface/transformers', 'onnxruntime-node'],
|
||||
|
||||
// Turbopack配置(Next.js 16默认启用)
|
||||
turbopack: {},
|
||||
|
||||
54
furniture-3d-system/package-lock.json
generated
54
furniture-3d-system/package-lock.json
generated
@ -26,7 +26,6 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dockerode": "^4.0.9",
|
||||
"exceljs": "^4.4.0",
|
||||
@ -43,7 +42,7 @@
|
||||
"next": "^16.1.6",
|
||||
"obj2gltf": "^3.2.0",
|
||||
"pg": "^8.17.2",
|
||||
"pinyin-pro": "^3.29.5",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
"postcss": "^8.5.6",
|
||||
"print-js": "^1.6.0",
|
||||
"puppeteer": "^24.40.0",
|
||||
@ -53,12 +52,10 @@
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"ssh2": "^1.17.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "^0.182.0",
|
||||
"uuid": "^11.1.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.2"
|
||||
@ -69,7 +66,6 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/dockerode": "^4.0.1",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"@types/jest": "^30.0.0",
|
||||
@ -6149,13 +6145,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/crypto-js": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
|
||||
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/docker-modem": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/@types/docker-modem/-/docker-modem-3.0.6.tgz",
|
||||
@ -8884,13 +8873,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
@ -9355,20 +9337,6 @@
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dockerode/node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@ -15537,9 +15505,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pinyin-pro": {
|
||||
"version": "3.29.5",
|
||||
"resolved": "https://registry.npmjs.org/pinyin-pro/-/pinyin-pro-3.29.5.tgz",
|
||||
"integrity": "sha512-Fk5YoRmtdHqJWqAuzYVG/NzvzeCbaG+ZEwbY6Nbps3QAIrvQQEtcm49frcyCM8i2b5najnzPDtYqrHRYTTiuwQ==",
|
||||
"version": "3.28.1",
|
||||
"resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.28.1.tgz",
|
||||
"integrity": "sha512-oqz8ulwRgtUXRi0vbqEfGNly19zpyCxYrjhkk5TibGcgSW6eNwS5woajCXRwqURi8Ehc2yOFTiB4uNoZ+NJOnA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pirates": {
|
||||
@ -16953,12 +16921,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/server-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
|
||||
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
@ -18870,16 +18832,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
|
||||
@ -3,9 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"worker:ai-image": "node scripts/run-ai-image-worker.mjs",
|
||||
"predev": "npm run prisma:check-client-sync",
|
||||
"dev": "next dev --webpack",
|
||||
"dev": "next dev",
|
||||
"build": "prisma generate && next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
@ -18,26 +17,10 @@
|
||||
"prisma:push": "node scripts/prisma-db-push-local.js",
|
||||
"prisma:studio": "prisma studio",
|
||||
"sync:supply-chain-product-archive": "tsx scripts/sync-supply-chain-product-archive.ts",
|
||||
"sync:supply-chain-product-archive-daily": "tsx scripts/sync-supply-chain-product-archive-sales-performance-daily.ts",
|
||||
"sync:supply-chain-product-archive-enrichment": "tsx scripts/sync-supply-chain-product-archive-enrichment.ts",
|
||||
"backfill:product-archive-designer": "tsx scripts/backfill-product-archive-designer-from-jst-sku-info-manage.ts",
|
||||
"backfill:product-archive-designer:apply": "tsx scripts/backfill-product-archive-designer-from-jst-sku-info-manage.ts --apply",
|
||||
"backfill:product-archive-opshead": "tsx scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts",
|
||||
"backfill:product-archive-opshead:apply": "tsx scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts --apply",
|
||||
"backfill:product-archive-opshead:regular": "tsx scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts --mode=regular",
|
||||
"backfill:product-archive-opshead:regular:apply": "tsx scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts --mode=regular --apply",
|
||||
"backfill:product-archive-production-type": "tsx scripts/backfill-product-archive-production-type.ts",
|
||||
"backfill:product-archive-production-type:apply": "tsx scripts/backfill-product-archive-production-type.ts --apply",
|
||||
"sync:supply-chain-product-archive-sales-performance": "tsx scripts/sync-supply-chain-product-archive-sales-performance.ts",
|
||||
"run:product-sabc": "tsx scripts/run-product-sabc.ts",
|
||||
"compare:product-sabc-xlsx": "tsx scripts/compare-product-sabc-xlsx.ts",
|
||||
"seed:local-product-sabc-xlsx": "tsx scripts/seed-local-product-sabc-xlsx.ts",
|
||||
"sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts",
|
||||
"sync:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts",
|
||||
"backtest:bom-cost-reference": "tsx scripts/backtest-bom-cost-reference.ts",
|
||||
"report:bom-cost-shadow-divergence": "tsx scripts/report-bom-cost-shadow-divergence.ts",
|
||||
"report:bom-agent-baseline": "tsx scripts/report-bom-agent-baseline.ts",
|
||||
"trial:bom-costing-agent": "tsx scripts/run-bom-costing-agent-trial.ts",
|
||||
"import:yunshan-created-at": "tsx scripts/import-yunshan-product-created-at.ts",
|
||||
"import:product-archive-drawing-media": "tsx scripts/import-product-archive-drawing-media.ts",
|
||||
"db:verify-master-data": "node scripts/verify-master-data-foundation.js",
|
||||
@ -47,8 +30,6 @@
|
||||
"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",
|
||||
@ -75,7 +56,6 @@
|
||||
"materials:sync-standard": "tsx scripts/sync-standard-finishes.ts",
|
||||
"materials:sync-standard:apply": "tsx scripts/sync-standard-finishes.ts --apply",
|
||||
"sync:jst-sku-stock": "tsx scripts/sync-jst-sku-stock-products.ts",
|
||||
"sync:product-inventory-snapshot": "tsx scripts/sync-product-inventory-snapshot.ts",
|
||||
"image-search:model:prepare": "tsx scripts/prepare-product-archive-image-search-model.ts",
|
||||
"image-search:index": "tsx scripts/index-product-archive-images.ts",
|
||||
"repair:barcode-print:historical": "tsx scripts/repair-barcode-print-source-rows.ts",
|
||||
@ -83,17 +63,12 @@
|
||||
"sync:oa-board:apply": "tsx scripts/sync-oa-board.ts --apply",
|
||||
"sync:oa-brand-planning": "tsx scripts/sync-oa-brand-planning-board.ts",
|
||||
"sync:oa-brand-planning:apply": "tsx scripts/sync-oa-brand-planning-board.ts --apply",
|
||||
"sync:oa-video-flow": "tsx scripts/sync-oa-video-flow-board.ts",
|
||||
"sync:oa-video-flow:apply": "tsx scripts/sync-oa-video-flow-board.ts --apply",
|
||||
"sync:oa-art-flow-raw": "tsx scripts/sync-oa-art-flow-raw.ts",
|
||||
"sync:oa-art-flow-raw:apply": "tsx scripts/sync-oa-art-flow-raw.ts --apply",
|
||||
"cache:oa-board:local": "tsx scripts/cache-oa-board-local-preview.ts",
|
||||
"import:oa-board-activity-mappings": "tsx scripts/import-oa-board-activity-mappings.ts",
|
||||
"import:oa-board-node-rules": "tsx scripts/import-oa-board-node-rules.ts",
|
||||
"report:oa-board-activities": "tsx scripts/report-oa-board-activities.ts",
|
||||
"sync:employee-directory": "tsx scripts/sync-employee-directory.ts",
|
||||
"sync:employee-directory:apply": "tsx scripts/sync-employee-directory.ts --apply",
|
||||
"sync:dingtalk-directory:apply": "tsx scripts/sync-dingtalk-directory.ts --apply",
|
||||
"sync:external-jsreport": "tsx scripts/sync-external-readonly-to-jsreport.ts",
|
||||
"sync:external-jsreport:apply": "tsx scripts/sync-external-readonly-to-jsreport.ts --apply",
|
||||
"db:verify-master-data:truth": "node scripts/verify-master-data-truth-sources.js",
|
||||
@ -146,7 +121,6 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dockerode": "^4.0.9",
|
||||
"exceljs": "^4.4.0",
|
||||
@ -163,7 +137,7 @@
|
||||
"next": "^16.1.6",
|
||||
"obj2gltf": "^3.2.0",
|
||||
"pg": "^8.17.2",
|
||||
"pinyin-pro": "^3.29.5",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
"postcss": "^8.5.6",
|
||||
"print-js": "^1.6.0",
|
||||
"puppeteer": "^24.40.0",
|
||||
@ -173,12 +147,10 @@
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"ssh2": "^1.17.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "^0.182.0",
|
||||
"uuid": "^11.1.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.2"
|
||||
@ -212,7 +184,6 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/dockerode": "^4.0.1",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"@types/jest": "^30.0.0",
|
||||
@ -235,20 +206,5 @@
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
"@prisma/engines",
|
||||
"cpu-features",
|
||||
"esbuild",
|
||||
"onnxruntime-node",
|
||||
"prisma",
|
||||
"protobufjs",
|
||||
"puppeteer",
|
||||
"sharp",
|
||||
"ssh2",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
UPDATE "SampleProgressItem"
|
||||
SET "openingCode" = upper(btrim("openingCode"))
|
||||
WHERE "openingCode" <> upper(btrim("openingCode"));
|
||||
|
||||
CREATE UNIQUE INDEX "SampleProgressItem_openingCode_normalized_key"
|
||||
ON "SampleProgressItem" (upper(btrim("openingCode")));
|
||||
|
||||
ALTER TABLE "SampleProgressItem"
|
||||
ADD CONSTRAINT "SampleProgressItem_openingCode_normalized_check"
|
||||
CHECK ("openingCode" = upper(btrim("openingCode")) AND "openingCode" <> '');
|
||||
@ -1,6 +0,0 @@
|
||||
ALTER TABLE "SampleProgressItem"
|
||||
DROP CONSTRAINT "SampleProgressItem_status_allowed";
|
||||
|
||||
ALTER TABLE "SampleProgressItem"
|
||||
ADD CONSTRAINT "SampleProgressItem_status_allowed"
|
||||
CHECK ("status" IN ('BARE_WOOD', 'BARE_WOOD_REVIEW', 'PAINT', 'VIDEO_REVIEW', 'SAMPLE_SHIPPING', 'PHYSICAL_REVIEW', 'COMPLETED', 'CANCELLED', 'TERMINATED'));
|
||||
@ -1,2 +0,0 @@
|
||||
ALTER TABLE "BomCostingAnalysisRun"
|
||||
ADD COLUMN "resolvedStructureKnowledgeVersion" TEXT NOT NULL DEFAULT '';
|
||||
@ -1,4 +0,0 @@
|
||||
ALTER TABLE "SupplyChainProductArchive"
|
||||
ADD COLUMN "opsDirector" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "opsDeptHead" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "opsHead" TEXT NOT NULL DEFAULT '';
|
||||
@ -1,6 +0,0 @@
|
||||
ALTER TABLE "SampleProgressItem"
|
||||
DROP CONSTRAINT "SampleProgressItem_status_allowed";
|
||||
|
||||
ALTER TABLE "SampleProgressItem"
|
||||
ADD CONSTRAINT "SampleProgressItem_status_allowed"
|
||||
CHECK ("status" IN ('BARE_WOOD', 'BARE_WOOD_REVIEW', 'PAINT', 'VIDEO_REVIEW', 'SAMPLE_SHIPPING', 'PHYSICAL_REVIEW', 'COMPLETED', 'NO_SAMPLE', 'CANCELLED', 'TERMINATED'));
|
||||
@ -1,6 +0,0 @@
|
||||
ALTER TABLE "SampleProgressItem"
|
||||
DROP CONSTRAINT "SampleProgressItem_status_allowed";
|
||||
|
||||
ALTER TABLE "SampleProgressItem"
|
||||
ADD CONSTRAINT "SampleProgressItem_status_allowed"
|
||||
CHECK ("status" IN ('', 'BARE_WOOD', 'BARE_WOOD_REVIEW', 'PAINT', 'VIDEO_REVIEW', 'SAMPLE_SHIPPING', 'PHYSICAL_REVIEW', 'COMPLETED', 'NO_SAMPLE', 'CANCELLED', 'TERMINATED'));
|
||||
@ -1,2 +0,0 @@
|
||||
ALTER TABLE "SampleProgressItem"
|
||||
ALTER COLUMN "sampleQuantity" DROP NOT NULL;
|
||||
@ -1,52 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TEMP TABLE "_retired_js_sku_info_manage_archives" ON COMMIT DROP AS
|
||||
SELECT
|
||||
"id",
|
||||
"fieldSourceMap"->'opsHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE' AS "clearOpsHead",
|
||||
"fieldSourceMap"->'opsDeptHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE' AS "clearOpsDeptHead",
|
||||
"fieldSourceMap"->'designer'->>'sourceCode' = 'JST_SKU_INFO_MANAGE' AS "clearDesigner",
|
||||
(
|
||||
"fieldSourceMap"->'opsHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE'
|
||||
AND "fieldSourceMap"->'opsDirector'->>'sourceCode' = 'SYSTEM_USER_DEPARTMENT'
|
||||
) AS "clearOpsDirector",
|
||||
ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN "fieldSourceMap"->'opsHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE' THEN 'opsHead' END,
|
||||
CASE WHEN "fieldSourceMap"->'opsDeptHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE' THEN 'opsDeptHead' END,
|
||||
CASE WHEN "fieldSourceMap"->'designer'->>'sourceCode' = 'JST_SKU_INFO_MANAGE' THEN 'designer' END,
|
||||
CASE WHEN
|
||||
"fieldSourceMap"->'opsHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE'
|
||||
AND "fieldSourceMap"->'opsDirector'->>'sourceCode' = 'SYSTEM_USER_DEPARTMENT'
|
||||
THEN 'opsDirector'
|
||||
END
|
||||
], NULL)::text[] AS "sourceKeys"
|
||||
FROM "SupplyChainProductArchive"
|
||||
WHERE
|
||||
"fieldSourceMap"->'opsHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE'
|
||||
OR "fieldSourceMap"->'opsDeptHead'->>'sourceCode' = 'JST_SKU_INFO_MANAGE'
|
||||
OR "fieldSourceMap"->'designer'->>'sourceCode' = 'JST_SKU_INFO_MANAGE';
|
||||
|
||||
CREATE INDEX ON "_retired_js_sku_info_manage_archives" ("id");
|
||||
|
||||
UPDATE "SupplyChainProductArchive" AS archive
|
||||
SET
|
||||
"opsHead" = CASE WHEN retired."clearOpsHead" THEN '' ELSE archive."opsHead" END,
|
||||
"opsDeptHead" = CASE WHEN retired."clearOpsDeptHead" THEN '' ELSE archive."opsDeptHead" END,
|
||||
"designer" = CASE WHEN retired."clearDesigner" THEN '' ELSE archive."designer" END,
|
||||
"opsDirector" = CASE WHEN retired."clearOpsDirector" THEN '' ELSE archive."opsDirector" END,
|
||||
"fieldSourceMap" = archive."fieldSourceMap" - retired."sourceKeys",
|
||||
"updatedAt" = CURRENT_TIMESTAMP
|
||||
FROM "_retired_js_sku_info_manage_archives" AS retired
|
||||
WHERE archive."id" = retired."id";
|
||||
|
||||
DELETE FROM "SupplyChainProductArchiveSource"
|
||||
WHERE "sourceCode" = 'JST_SKU_INFO_MANAGE';
|
||||
|
||||
DELETE FROM "SupplyChainProductArchiveSource" AS source
|
||||
USING "_retired_js_sku_info_manage_archives" AS retired
|
||||
WHERE
|
||||
source."archiveId" = retired."id"
|
||||
AND retired."clearOpsDirector"
|
||||
AND source."sourceCode" = 'SYSTEM_USER_DEPARTMENT';
|
||||
|
||||
COMMIT;
|
||||
@ -1,31 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS "SupplyChainPermissionOverride" (
|
||||
"id" TEXT NOT NULL,
|
||||
"targetKey" TEXT NOT NULL,
|
||||
"subjectKey" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdBy" TEXT,
|
||||
"updatedBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "SupplyChainPermissionOverride_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "SupplyChainPermissionOverride_identity_valid" CHECK (
|
||||
NULLIF(btrim("targetKey"), '') IS NOT NULL
|
||||
AND "targetKey" = btrim("targetKey")
|
||||
AND NULLIF(btrim("subjectKey"), '') IS NOT NULL
|
||||
AND "subjectKey" = btrim("subjectKey")
|
||||
AND NULLIF(btrim("action"), '') IS NOT NULL
|
||||
AND "action" = btrim("action")
|
||||
),
|
||||
CONSTRAINT "SupplyChainPermissionOverride_audit_text_not_blank" CHECK (
|
||||
("createdBy" IS NULL OR NULLIF(btrim("createdBy"), '') IS NOT NULL)
|
||||
AND ("updatedBy" IS NULL OR NULLIF(btrim("updatedBy"), '') IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "SupplyChainPermissionOverride_targetKey_subjectKey_action_key"
|
||||
ON "SupplyChainPermissionOverride" ("targetKey", "subjectKey", "action");
|
||||
CREATE INDEX IF NOT EXISTS "SupplyChainPermissionOverride_targetKey_idx"
|
||||
ON "SupplyChainPermissionOverride" ("targetKey");
|
||||
CREATE INDEX IF NOT EXISTS "SupplyChainPermissionOverride_subjectKey_idx"
|
||||
ON "SupplyChainPermissionOverride" ("subjectKey");
|
||||
@ -1,5 +0,0 @@
|
||||
-- Retain the auditable realtime 1688 search result with every scenario
|
||||
-- revision. It is a whole-product procurement signal, not a line-level BOM
|
||||
-- price, therefore it stays as a single JSON snapshot on the scenario.
|
||||
ALTER TABLE "BomCostingScenario"
|
||||
ADD COLUMN "realtimeMarketQuote" JSONB;
|
||||
@ -1,35 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "SupplyChainProductArchiveSalesPerformance" (
|
||||
"archiveId" TEXT NOT NULL,
|
||||
"barcode" TEXT NOT NULL,
|
||||
"periodStart" DATE NOT NULL,
|
||||
"periodEnd" DATE NOT NULL,
|
||||
"shipAmount" DECIMAL(24,4) NOT NULL,
|
||||
"returnAmount" DECIMAL(24,4) NOT NULL,
|
||||
"realPerformance" DECIMAL(24,4) NOT NULL,
|
||||
"shipQty" BIGINT NOT NULL,
|
||||
"returnQty" BIGINT NOT NULL,
|
||||
"grossProfit" DECIMAL(24,4) NOT NULL,
|
||||
"grossMargin" DECIMAL(20,8),
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SupplyChainProductArchiveSalesPerformance_pkey" PRIMARY KEY ("archiveId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SupplyChainProductArchiveSalesPerformance_barcode_key"
|
||||
ON "SupplyChainProductArchiveSalesPerformance"("barcode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SupplyChainProductArchiveSalesPerformance_periodEnd_idx"
|
||||
ON "SupplyChainProductArchiveSalesPerformance"("periodEnd");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SupplyChainProductArchiveSalesPerformance_syncedAt_idx"
|
||||
ON "SupplyChainProductArchiveSalesPerformance"("syncedAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SupplyChainProductArchiveSalesPerformance"
|
||||
ADD CONSTRAINT "SupplyChainProductArchiveSalesPerformance_archiveId_fkey"
|
||||
FOREIGN KEY ("archiveId") REFERENCES "SupplyChainProductArchive"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -1,5 +0,0 @@
|
||||
-- Keeps the server-only audit for whole-SKU agent estimates. Public BOM DTOs
|
||||
-- deliberately omit this field, so it cannot reveal private model context or
|
||||
-- internal reference records to browsers or exports.
|
||||
ALTER TABLE "BomCostingScenario"
|
||||
ADD COLUMN "wholeProductEstimateAudit" JSONB;
|
||||
@ -1,4 +0,0 @@
|
||||
ALTER TABLE "SampleProgressItem"
|
||||
ADD COLUMN "supplierConfirmedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "sampleNotifiedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "courierTrackingNumber" TEXT;
|
||||
@ -1,4 +0,0 @@
|
||||
-- Add derived fee ratio and operating profit to the existing sales snapshot.
|
||||
ALTER TABLE "SupplyChainProductArchiveSalesPerformance"
|
||||
ADD COLUMN "feeRatio" DECIMAL(20,8),
|
||||
ADD COLUMN "operatingProfit" DECIMAL(24,4);
|
||||
@ -1,2 +0,0 @@
|
||||
ALTER TABLE "SupplyChainProductArchive"
|
||||
ADD COLUMN "stockClearance" TEXT NOT NULL DEFAULT '';
|
||||
@ -1,18 +0,0 @@
|
||||
CREATE TABLE "NewProductTerminationFeedback" (
|
||||
"id" TEXT NOT NULL,
|
||||
"openingCode" TEXT NOT NULL,
|
||||
"reasonCategory" TEXT NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"feedbackById" TEXT,
|
||||
"feedbackByName" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "NewProductTerminationFeedback_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "NewProductTerminationFeedback_openingCode_key"
|
||||
ON "NewProductTerminationFeedback"("openingCode");
|
||||
|
||||
CREATE INDEX "NewProductTerminationFeedback_updatedAt_idx"
|
||||
ON "NewProductTerminationFeedback"("updatedAt");
|
||||
@ -1,24 +0,0 @@
|
||||
CREATE TABLE "PermissionRole" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"normalizedName" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PermissionRole_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
ALTER TABLE "User" ADD COLUMN "permissionRoleId" TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX "PermissionRole_normalizedName_key" ON "PermissionRole"("normalizedName");
|
||||
CREATE INDEX "PermissionRole_isActive_sortOrder_idx" ON "PermissionRole"("isActive", "sortOrder");
|
||||
CREATE INDEX "User_permissionRoleId_idx" ON "User"("permissionRoleId");
|
||||
|
||||
ALTER TABLE "User"
|
||||
ADD CONSTRAINT "User_permissionRoleId_fkey"
|
||||
FOREIGN KEY ("permissionRoleId") REFERENCES "PermissionRole"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -1,21 +0,0 @@
|
||||
CREATE TABLE "NewProductTerminationReasonArchive" (
|
||||
"id" TEXT NOT NULL,
|
||||
"processInstanceId" TEXT NOT NULL,
|
||||
"openingCode" TEXT,
|
||||
"reasonCategory" TEXT NOT NULL,
|
||||
"reason" TEXT,
|
||||
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "NewProductTerminationReasonArchive_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "NewProductTerminationReasonArchive_processInstanceId_key"
|
||||
ON "NewProductTerminationReasonArchive"("processInstanceId");
|
||||
|
||||
CREATE INDEX "NewProductTerminationReasonArchive_openingCode_idx"
|
||||
ON "NewProductTerminationReasonArchive"("openingCode");
|
||||
|
||||
CREATE INDEX "NewProductTerminationReasonArchive_fetchedAt_idx"
|
||||
ON "NewProductTerminationReasonArchive"("fetchedAt");
|
||||
@ -1,3 +0,0 @@
|
||||
ALTER TABLE "SampleProgressItem"
|
||||
ADD COLUMN "sampleStandardDays" DOUBLE PRECISION,
|
||||
ADD COLUMN "oaSampleActualDays" DOUBLE PRECISION;
|
||||
@ -1,218 +0,0 @@
|
||||
-- Barcode printing starts when basic information is confirmed and the SKU's
|
||||
-- product-factory row is confirmed.
|
||||
|
||||
CREATE OR REPLACE FUNCTION "sync_barcode_print_system_sku"(
|
||||
sku_item_record_id TEXT
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
resolved_sku_code TEXT;
|
||||
source_product_id TEXT;
|
||||
source_sku_item_id TEXT;
|
||||
source_created_by TEXT;
|
||||
source_updated_at TIMESTAMP;
|
||||
BEGIN
|
||||
SELECT
|
||||
sku."productId",
|
||||
sku."skuItemId",
|
||||
COALESCE(NULLIF(BTRIM(sku."skuCode"), ''), NULLIF(BTRIM(factory_row ->> 'skuCode'), '')),
|
||||
product."createdBy",
|
||||
product."updatedAt"
|
||||
INTO
|
||||
source_product_id,
|
||||
source_sku_item_id,
|
||||
resolved_sku_code,
|
||||
source_created_by,
|
||||
source_updated_at
|
||||
FROM "IntakeNonIntegratedSkuItem" sku
|
||||
INNER JOIN "IntakeNonIntegratedProduct" product
|
||||
ON product."id" = sku."productId"
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
CASE
|
||||
WHEN jsonb_typeof(product."payload" -> 'factoryRows') = 'array'
|
||||
THEN product."payload" -> 'factoryRows'
|
||||
ELSE '[]'::jsonb
|
||||
END
|
||||
) AS factory_row
|
||||
WHERE sku."id" = sku_item_record_id
|
||||
AND product."deletedAt" IS NULL
|
||||
AND product."payload" -> 'documentWorkspaceConfirmations' ->> '基础信息' = 'true'
|
||||
AND NULLIF(
|
||||
BTRIM(product."payload" -> 'documentWorkspaceApprovedAt' ->> '基础信息'),
|
||||
''
|
||||
) IS NOT NULL
|
||||
AND factory_row ->> 'skuId' = sku."skuItemId"
|
||||
AND factory_row ->> 'confirmed' = 'true';
|
||||
|
||||
IF source_product_id IS NULL OR source_sku_item_id IS NULL OR resolved_sku_code IS NULL THEN
|
||||
DELETE FROM "BarcodePrintImportRecord"
|
||||
WHERE "sourcePricingRowId" = sku_item_record_id;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
INSERT INTO "BarcodePrintImportRecord" (
|
||||
"id", "kind", "sourcePricingRowId", "skuCode", "normalizedSku",
|
||||
"supplierCode", "supplierName", "normalizedSupplierName",
|
||||
"sourceFileName", "rowNumber", "importedAt", "updates", "createdBy",
|
||||
"createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
'barcode-print-system-sku-' || sku."id",
|
||||
'barcode',
|
||||
sku."id",
|
||||
resolved_sku_code,
|
||||
UPPER(BTRIM(resolved_sku_code)),
|
||||
COALESCE(sku."manufacturerCode", ''),
|
||||
COALESCE(sku."manufacturer", ''),
|
||||
LOWER(BTRIM(COALESCE(sku."manufacturer", ''))),
|
||||
'产品定厂自动进入',
|
||||
0,
|
||||
source_updated_at,
|
||||
jsonb_build_object(
|
||||
'spu', product."spu",
|
||||
'model', sku."model",
|
||||
'skuCode', resolved_sku_code,
|
||||
'barcode', sku."barcode",
|
||||
'barcodeSpecName', sku."specName",
|
||||
'attributeCode', sku."attributeCode",
|
||||
'drawingId', sku."drawingId",
|
||||
'productCategory', COALESCE(profile."productCategory", ''),
|
||||
'salesCategory', COALESCE(profile."salesCategory", ''),
|
||||
'materialType', COALESCE(profile."materialType", sku."boardMaterialType", ''),
|
||||
'size', COALESCE(NULLIF(sku."size", ''), profile."size", ''),
|
||||
'color', COALESCE(NULLIF(sku."color", ''), profile."color", ''),
|
||||
'material', sku."material",
|
||||
'executeStandard', sku."executeStandard",
|
||||
'tagMaterial', COALESCE(NULLIF(profile."tagMaterial", ''), sku."material", ''),
|
||||
'supplierCode', sku."manufacturerCode",
|
||||
'supplierName', sku."manufacturer",
|
||||
'costPrice', COALESCE(pricing."costPrice"::TEXT, ''),
|
||||
'productPlanning', COALESCE(profile."productPlanning", ''),
|
||||
'visualPlanning', COALESCE(profile."visualPlanning", ''),
|
||||
'spuOwner', COALESCE(profile."spuOwner", ''),
|
||||
'operationsOwner', COALESCE(profile."operationsOwner", product."operator", ''),
|
||||
'purchaseOwner', COALESCE(profile."purchaseOwner", product."purchaser", ''),
|
||||
'productionType', COALESCE(profile."productionType", ''),
|
||||
'assemblyDetail', sku."installMethod",
|
||||
'createdAt', TO_CHAR(source_updated_at, 'YYYY-MM-DD'),
|
||||
'dataSource', '流程'
|
||||
),
|
||||
source_created_by,
|
||||
source_updated_at,
|
||||
NOW()
|
||||
FROM "IntakeNonIntegratedSkuItem" sku
|
||||
INNER JOIN "IntakeNonIntegratedProduct" product
|
||||
ON product."id" = sku."productId"
|
||||
LEFT JOIN "IntakeNonIntegratedProductProfile" profile
|
||||
ON profile."productId" = product."id"
|
||||
LEFT JOIN "IntakeNonIntegratedPricingRow" pricing
|
||||
ON pricing."productId" = sku."productId"
|
||||
AND pricing."skuItemId" = sku."skuItemId"
|
||||
WHERE sku."id" = sku_item_record_id
|
||||
ON CONFLICT ("sourcePricingRowId") DO UPDATE
|
||||
SET
|
||||
"skuCode" = EXCLUDED."skuCode",
|
||||
"normalizedSku" = EXCLUDED."normalizedSku",
|
||||
"supplierCode" = EXCLUDED."supplierCode",
|
||||
"supplierName" = EXCLUDED."supplierName",
|
||||
"normalizedSupplierName" = EXCLUDED."normalizedSupplierName",
|
||||
"sourceFileName" = EXCLUDED."sourceFileName",
|
||||
"updates" = EXCLUDED."updates",
|
||||
"updatedAt" = EXCLUDED."updatedAt";
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "sync_barcode_print_system_sku_trigger"()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
DELETE FROM "BarcodePrintImportRecord"
|
||||
WHERE "sourcePricingRowId" = OLD."id";
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
PERFORM "sync_barcode_print_system_sku"(NEW."id");
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS "sync_barcode_print_system_pricing_row_trigger"
|
||||
ON "IntakeNonIntegratedPricingRow";
|
||||
DROP FUNCTION IF EXISTS "sync_barcode_print_system_pricing_row"();
|
||||
|
||||
CREATE OR REPLACE FUNCTION "refresh_barcode_print_system_sku_from_pricing"()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
source_product_id TEXT;
|
||||
source_sku_item_id TEXT;
|
||||
sku_item_record_id TEXT;
|
||||
BEGIN
|
||||
source_product_id := CASE WHEN TG_OP = 'DELETE' THEN OLD."productId" ELSE NEW."productId" END;
|
||||
source_sku_item_id := CASE WHEN TG_OP = 'DELETE' THEN OLD."skuItemId" ELSE NEW."skuItemId" END;
|
||||
|
||||
SELECT sku."id"
|
||||
INTO sku_item_record_id
|
||||
FROM "IntakeNonIntegratedSkuItem" sku
|
||||
WHERE sku."productId" = source_product_id
|
||||
AND sku."skuItemId" = source_sku_item_id;
|
||||
|
||||
IF sku_item_record_id IS NOT NULL THEN
|
||||
PERFORM "sync_barcode_print_system_sku"(sku_item_record_id);
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS "refresh_barcode_print_system_sku_from_pricing_trigger"
|
||||
ON "IntakeNonIntegratedPricingRow";
|
||||
CREATE TRIGGER "refresh_barcode_print_system_sku_from_pricing_trigger"
|
||||
AFTER INSERT OR UPDATE OR DELETE ON "IntakeNonIntegratedPricingRow"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "refresh_barcode_print_system_sku_from_pricing"();
|
||||
|
||||
DROP TRIGGER IF EXISTS "sync_barcode_print_system_sku_trigger"
|
||||
ON "IntakeNonIntegratedSkuItem";
|
||||
CREATE TRIGGER "sync_barcode_print_system_sku_trigger"
|
||||
AFTER INSERT OR UPDATE OR DELETE ON "IntakeNonIntegratedSkuItem"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "sync_barcode_print_system_sku_trigger"();
|
||||
|
||||
CREATE OR REPLACE FUNCTION "sync_barcode_print_system_product"()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW."payload" IS DISTINCT FROM OLD."payload"
|
||||
OR NEW."deletedAt" IS DISTINCT FROM OLD."deletedAt" THEN
|
||||
PERFORM "sync_barcode_print_system_sku"(sku."id")
|
||||
FROM "IntakeNonIntegratedSkuItem" sku
|
||||
WHERE sku."productId" = NEW."id";
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS "sync_barcode_print_system_product_factory_trigger"
|
||||
ON "IntakeNonIntegratedProduct";
|
||||
CREATE TRIGGER "sync_barcode_print_system_product_factory_trigger"
|
||||
AFTER UPDATE OF "payload", "deletedAt" ON "IntakeNonIntegratedProduct"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "sync_barcode_print_system_product"();
|
||||
|
||||
-- Replace old system projections only. The mapped sourceSkuItemId is NULL for
|
||||
-- manual imports, so manually imported barcode rows remain untouched.
|
||||
DELETE FROM "BarcodePrintImportRecord"
|
||||
WHERE "sourcePricingRowId" IS NOT NULL;
|
||||
|
||||
SELECT "sync_barcode_print_system_sku"(sku."id")
|
||||
FROM "IntakeNonIntegratedSkuItem" sku;
|
||||
@ -1,154 +0,0 @@
|
||||
-- Keep the system barcode projection aligned with the live row builder:
|
||||
-- factoryModel is the product model followed by the confirmed factory code.
|
||||
|
||||
CREATE OR REPLACE FUNCTION "sync_barcode_print_system_sku"(
|
||||
sku_item_record_id TEXT
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
resolved_sku_code TEXT;
|
||||
resolved_model TEXT;
|
||||
resolved_supplier_code TEXT;
|
||||
resolved_factory_model TEXT;
|
||||
source_product_id TEXT;
|
||||
source_sku_item_id TEXT;
|
||||
source_created_by TEXT;
|
||||
source_updated_at TIMESTAMP;
|
||||
BEGIN
|
||||
SELECT
|
||||
sku."productId",
|
||||
sku."skuItemId",
|
||||
COALESCE(NULLIF(BTRIM(sku."skuCode"), ''), NULLIF(BTRIM(factory_row ->> 'skuCode'), '')),
|
||||
COALESCE(
|
||||
NULLIF(BTRIM(sku."model"), ''),
|
||||
NULLIF(BTRIM(factory_row ->> 'model'), ''),
|
||||
NULLIF(BTRIM(product."payload" ->> 'productCode'), ''),
|
||||
''
|
||||
),
|
||||
UPPER(COALESCE(
|
||||
NULLIF(BTRIM(factory_row ->> 'supplierCode'), ''),
|
||||
NULLIF(BTRIM(sku."manufacturerCode"), ''),
|
||||
''
|
||||
)),
|
||||
product."createdBy",
|
||||
product."updatedAt"
|
||||
INTO
|
||||
source_product_id,
|
||||
source_sku_item_id,
|
||||
resolved_sku_code,
|
||||
resolved_model,
|
||||
resolved_supplier_code,
|
||||
source_created_by,
|
||||
source_updated_at
|
||||
FROM "IntakeNonIntegratedSkuItem" sku
|
||||
INNER JOIN "IntakeNonIntegratedProduct" product
|
||||
ON product."id" = sku."productId"
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
CASE
|
||||
WHEN jsonb_typeof(product."payload" -> 'factoryRows') = 'array'
|
||||
THEN product."payload" -> 'factoryRows'
|
||||
ELSE '[]'::jsonb
|
||||
END
|
||||
) AS factory_row
|
||||
WHERE sku."id" = sku_item_record_id
|
||||
AND product."deletedAt" IS NULL
|
||||
AND product."payload" -> 'documentWorkspaceConfirmations' ->> '基础信息' = 'true'
|
||||
AND NULLIF(
|
||||
BTRIM(product."payload" -> 'documentWorkspaceApprovedAt' ->> '基础信息'),
|
||||
''
|
||||
) IS NOT NULL
|
||||
AND factory_row ->> 'skuId' = sku."skuItemId"
|
||||
AND factory_row ->> 'confirmed' = 'true';
|
||||
|
||||
IF source_product_id IS NULL OR source_sku_item_id IS NULL OR resolved_sku_code IS NULL THEN
|
||||
DELETE FROM "BarcodePrintImportRecord"
|
||||
WHERE "sourcePricingRowId" = sku_item_record_id;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
resolved_factory_model := CASE
|
||||
WHEN resolved_supplier_code = ''
|
||||
OR POSITION(UPPER(resolved_supplier_code) IN UPPER(resolved_model)) > 0
|
||||
THEN resolved_model
|
||||
ELSE resolved_model || resolved_supplier_code
|
||||
END;
|
||||
|
||||
INSERT INTO "BarcodePrintImportRecord" (
|
||||
"id", "kind", "sourcePricingRowId", "skuCode", "normalizedSku",
|
||||
"supplierCode", "supplierName", "normalizedSupplierName",
|
||||
"sourceFileName", "rowNumber", "importedAt", "updates", "createdBy",
|
||||
"createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
'barcode-print-system-sku-' || sku."id",
|
||||
'barcode',
|
||||
sku."id",
|
||||
resolved_sku_code,
|
||||
UPPER(BTRIM(resolved_sku_code)),
|
||||
resolved_supplier_code,
|
||||
COALESCE(sku."manufacturer", ''),
|
||||
LOWER(BTRIM(COALESCE(sku."manufacturer", ''))),
|
||||
'产品定厂自动进入',
|
||||
0,
|
||||
source_updated_at,
|
||||
jsonb_build_object(
|
||||
'spu', product."spu",
|
||||
'model', resolved_model,
|
||||
'skuCode', resolved_sku_code,
|
||||
'barcode', sku."barcode",
|
||||
'factoryModel', resolved_factory_model,
|
||||
'barcodeSpecName', sku."specName",
|
||||
'attributeCode', sku."attributeCode",
|
||||
'drawingId', sku."drawingId",
|
||||
'productCategory', COALESCE(profile."productCategory", ''),
|
||||
'salesCategory', COALESCE(profile."salesCategory", ''),
|
||||
'materialType', COALESCE(profile."materialType", sku."boardMaterialType", ''),
|
||||
'size', COALESCE(NULLIF(sku."size", ''), profile."size", ''),
|
||||
'color', COALESCE(NULLIF(sku."color", ''), profile."color", ''),
|
||||
'material', sku."material",
|
||||
'executeStandard', sku."executeStandard",
|
||||
'tagMaterial', COALESCE(NULLIF(profile."tagMaterial", ''), sku."material", ''),
|
||||
'supplierCode', resolved_supplier_code,
|
||||
'supplierName', sku."manufacturer",
|
||||
'costPrice', COALESCE(pricing."costPrice"::TEXT, ''),
|
||||
'productPlanning', COALESCE(profile."productPlanning", ''),
|
||||
'visualPlanning', COALESCE(profile."visualPlanning", ''),
|
||||
'spuOwner', COALESCE(profile."spuOwner", ''),
|
||||
'operationsOwner', COALESCE(profile."operationsOwner", product."operator", ''),
|
||||
'purchaseOwner', COALESCE(profile."purchaseOwner", product."purchaser", ''),
|
||||
'productionType', COALESCE(profile."productionType", ''),
|
||||
'assemblyDetail', sku."installMethod",
|
||||
'createdAt', TO_CHAR(source_updated_at, 'YYYY-MM-DD'),
|
||||
'dataSource', '流程'
|
||||
),
|
||||
source_created_by,
|
||||
source_updated_at,
|
||||
NOW()
|
||||
FROM "IntakeNonIntegratedSkuItem" sku
|
||||
INNER JOIN "IntakeNonIntegratedProduct" product
|
||||
ON product."id" = sku."productId"
|
||||
LEFT JOIN "IntakeNonIntegratedProductProfile" profile
|
||||
ON profile."productId" = product."id"
|
||||
LEFT JOIN "IntakeNonIntegratedPricingRow" pricing
|
||||
ON pricing."productId" = sku."productId"
|
||||
AND pricing."skuItemId" = sku."skuItemId"
|
||||
WHERE sku."id" = sku_item_record_id
|
||||
ON CONFLICT ("sourcePricingRowId") DO UPDATE
|
||||
SET
|
||||
"skuCode" = EXCLUDED."skuCode",
|
||||
"normalizedSku" = EXCLUDED."normalizedSku",
|
||||
"supplierCode" = EXCLUDED."supplierCode",
|
||||
"supplierName" = EXCLUDED."supplierName",
|
||||
"normalizedSupplierName" = EXCLUDED."normalizedSupplierName",
|
||||
"sourceFileName" = EXCLUDED."sourceFileName",
|
||||
"updates" = EXCLUDED."updates",
|
||||
"updatedAt" = EXCLUDED."updatedAt";
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Rebuild system-derived rows. Manual imports keep sourcePricingRowId NULL and
|
||||
-- therefore cannot conflict with, or be deleted by, this reconciliation.
|
||||
SELECT "sync_barcode_print_system_sku"(sku."id")
|
||||
FROM "IntakeNonIntegratedSkuItem" sku;
|
||||
@ -1,3 +0,0 @@
|
||||
ALTER TABLE "SupplyChainProductArchive"
|
||||
ADD COLUMN "stableLeadTime" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "backupSupplier" TEXT NOT NULL DEFAULT '';
|
||||
@ -1,55 +0,0 @@
|
||||
CREATE TABLE "StoreAccount" (
|
||||
"id" TEXT NOT NULL,
|
||||
"storeName" TEXT NOT NULL,
|
||||
"operationOwner" TEXT NOT NULL DEFAULT '',
|
||||
"operationDepartmentHead" TEXT NOT NULL DEFAULT '',
|
||||
"operationDirector" TEXT NOT NULL DEFAULT '',
|
||||
"profitCenter" TEXT NOT NULL DEFAULT '',
|
||||
"accountType" TEXT NOT NULL,
|
||||
"account" TEXT NOT NULL,
|
||||
"passwordCiphertext" TEXT NOT NULL DEFAULT '',
|
||||
"boundPhone" TEXT NOT NULL DEFAULT '',
|
||||
"verificationContact" TEXT NOT NULL DEFAULT '',
|
||||
"jstOrderService" TEXT NOT NULL DEFAULT '',
|
||||
"accountGroupName" TEXT NOT NULL DEFAULT '',
|
||||
"createdById" TEXT,
|
||||
"updatedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "StoreAccount_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "StoreAccount_storeName_accountType_account_key" ON "StoreAccount"("storeName", "accountType", "account");
|
||||
CREATE INDEX "StoreAccount_storeName_idx" ON "StoreAccount"("storeName");
|
||||
CREATE INDEX "StoreAccount_account_idx" ON "StoreAccount"("account");
|
||||
CREATE INDEX "StoreAccount_accountType_idx" ON "StoreAccount"("accountType");
|
||||
CREATE INDEX "StoreAccount_updatedAt_idx" ON "StoreAccount"("updatedAt");
|
||||
|
||||
CREATE TABLE "StoreAccountChangeLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"storeAccountId" TEXT,
|
||||
"storeName" TEXT NOT NULL,
|
||||
"operation" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"actorName" TEXT NOT NULL,
|
||||
"changes" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "StoreAccountChangeLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX "StoreAccountChangeLog_storeAccountId_createdAt_idx" ON "StoreAccountChangeLog"("storeAccountId", "createdAt");
|
||||
CREATE INDEX "StoreAccountChangeLog_createdAt_idx" ON "StoreAccountChangeLog"("createdAt");
|
||||
CREATE INDEX "StoreAccountChangeLog_storeName_createdAt_idx" ON "StoreAccountChangeLog"("storeName", "createdAt");
|
||||
|
||||
CREATE TABLE "StoreAccountFieldPermission" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fieldKey" TEXT NOT NULL,
|
||||
"subjectKey" TEXT NOT NULL,
|
||||
"visibility" TEXT NOT NULL DEFAULT 'READONLY',
|
||||
"updatedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "StoreAccountFieldPermission_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE UNIQUE INDEX "StoreAccountFieldPermission_fieldKey_subjectKey_key" ON "StoreAccountFieldPermission"("fieldKey", "subjectKey");
|
||||
CREATE INDEX "StoreAccountFieldPermission_fieldKey_idx" ON "StoreAccountFieldPermission"("fieldKey");
|
||||
CREATE INDEX "StoreAccountFieldPermission_subjectKey_idx" ON "StoreAccountFieldPermission"("subjectKey");
|
||||
@ -1,2 +0,0 @@
|
||||
ALTER TABLE "SupplyChainProductArchiveWarehouseConfig"
|
||||
ADD COLUMN "warehouseOwnership" TEXT NOT NULL DEFAULT '';
|
||||
@ -1,48 +0,0 @@
|
||||
CREATE TABLE "NotificationDeliveryLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"idempotencyKey" TEXT NOT NULL,
|
||||
"payloadHash" TEXT NOT NULL,
|
||||
"externalDeliveryId" TEXT,
|
||||
"businessEvent" TEXT NOT NULL,
|
||||
"businessRecordId" TEXT,
|
||||
"source" TEXT NOT NULL DEFAULT 'SYSTEM',
|
||||
"initiatedById" TEXT,
|
||||
"initiatedByName" TEXT NOT NULL,
|
||||
"recipientUserId" TEXT NOT NULL,
|
||||
"recipientName" TEXT NOT NULL,
|
||||
"recipientEmail" TEXT NOT NULL,
|
||||
"recipientDingtalkUserId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"renderedTitle" TEXT,
|
||||
"content" TEXT NOT NULL,
|
||||
"actionTitle" TEXT NOT NULL,
|
||||
"actionUrl" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"lastErrorCode" TEXT,
|
||||
"lastErrorMessage" TEXT,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"readAt" TIMESTAMP(3),
|
||||
"lastSyncedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "NotificationDeliveryLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "NotificationDeliveryLog_idempotencyKey_key"
|
||||
ON "NotificationDeliveryLog"("idempotencyKey");
|
||||
|
||||
CREATE UNIQUE INDEX "NotificationDeliveryLog_externalDeliveryId_key"
|
||||
ON "NotificationDeliveryLog"("externalDeliveryId");
|
||||
|
||||
CREATE INDEX "NotificationDeliveryLog_status_createdAt_idx"
|
||||
ON "NotificationDeliveryLog"("status", "createdAt");
|
||||
|
||||
CREATE INDEX "NotificationDeliveryLog_recipientUserId_createdAt_idx"
|
||||
ON "NotificationDeliveryLog"("recipientUserId", "createdAt");
|
||||
|
||||
CREATE INDEX "NotificationDeliveryLog_businessEvent_createdAt_idx"
|
||||
ON "NotificationDeliveryLog"("businessEvent", "createdAt");
|
||||
|
||||
CREATE INDEX "NotificationDeliveryLog_createdAt_idx"
|
||||
ON "NotificationDeliveryLog"("createdAt");
|
||||
@ -1,23 +0,0 @@
|
||||
CREATE TABLE "ProductDevelopmentTodoAttempt" (
|
||||
"id" TEXT NOT NULL,
|
||||
"attemptKey" TEXT NOT NULL,
|
||||
"ruleId" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"recipientKey" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'claimed',
|
||||
"lastErrorCode" TEXT,
|
||||
"lastErrorMessage" TEXT,
|
||||
"attemptedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProductDevelopmentTodoAttempt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "ProductDevelopmentTodoAttempt_attemptKey_key"
|
||||
ON "ProductDevelopmentTodoAttempt"("attemptKey");
|
||||
|
||||
CREATE INDEX "ProductDevelopmentTodoAttempt_productId_ruleId_idx"
|
||||
ON "ProductDevelopmentTodoAttempt"("productId", "ruleId");
|
||||
|
||||
CREATE INDEX "ProductDevelopmentTodoAttempt_recipientKey_attemptedAt_idx"
|
||||
ON "ProductDevelopmentTodoAttempt"("recipientKey", "attemptedAt");
|
||||
@ -1,71 +0,0 @@
|
||||
CREATE TABLE "SampleProgressSupplier" (
|
||||
"id" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"supplier" TEXT NOT NULL,
|
||||
"supplierConfirmedAt" TIMESTAMP(3),
|
||||
"sampleNotifiedAt" TIMESTAMP(3),
|
||||
"sampleDueDate" DATE NOT NULL,
|
||||
"courierTrackingNumber" TEXT,
|
||||
"status" TEXT NOT NULL,
|
||||
"requestedCost" DECIMAL(12,2),
|
||||
"actualCost" DECIMAL(12,2),
|
||||
"progressDescription" TEXT,
|
||||
"actualSampleCompletedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SampleProgressSupplier_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "SampleProgressSupplier_itemId_sortOrder_key"
|
||||
ON "SampleProgressSupplier"("itemId", "sortOrder");
|
||||
|
||||
CREATE INDEX "SampleProgressSupplier_itemId_idx"
|
||||
ON "SampleProgressSupplier"("itemId");
|
||||
|
||||
CREATE INDEX "SampleProgressSupplier_status_idx"
|
||||
ON "SampleProgressSupplier"("status");
|
||||
|
||||
CREATE INDEX "SampleProgressSupplier_sampleDueDate_idx"
|
||||
ON "SampleProgressSupplier"("sampleDueDate");
|
||||
|
||||
ALTER TABLE "SampleProgressSupplier"
|
||||
ADD CONSTRAINT "SampleProgressSupplier_itemId_fkey"
|
||||
FOREIGN KEY ("itemId") REFERENCES "SampleProgressItem"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO "SampleProgressSupplier" (
|
||||
"id",
|
||||
"itemId",
|
||||
"sortOrder",
|
||||
"supplier",
|
||||
"supplierConfirmedAt",
|
||||
"sampleNotifiedAt",
|
||||
"sampleDueDate",
|
||||
"courierTrackingNumber",
|
||||
"status",
|
||||
"requestedCost",
|
||||
"actualCost",
|
||||
"progressDescription",
|
||||
"actualSampleCompletedAt",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
)
|
||||
SELECT
|
||||
'legacy_' || "id",
|
||||
"id",
|
||||
0,
|
||||
"supplier",
|
||||
"supplierConfirmedAt",
|
||||
"sampleNotifiedAt",
|
||||
"sampleDueDate",
|
||||
"courierTrackingNumber",
|
||||
"status",
|
||||
"requestedCost",
|
||||
"actualCost",
|
||||
"progressDescription",
|
||||
"actualSampleCompletedAt",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
FROM "SampleProgressItem";
|
||||
@ -1,49 +0,0 @@
|
||||
CREATE TABLE "FinishedProductProgressItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"contractNumber" TEXT NOT NULL,
|
||||
"imageUrl" TEXT,
|
||||
"itemNumber" TEXT NOT NULL DEFAULT '',
|
||||
"barcode" TEXT NOT NULL,
|
||||
"productName" TEXT NOT NULL DEFAULT '',
|
||||
"supplier" TEXT NOT NULL DEFAULT '',
|
||||
"brand" TEXT NOT NULL DEFAULT '',
|
||||
"orderedQuantity" INTEGER NOT NULL DEFAULT 0,
|
||||
"inTransitQuantity" INTEGER NOT NULL DEFAULT 0,
|
||||
"orderDueDate" DATE,
|
||||
"currentNode" TEXT NOT NULL DEFAULT '',
|
||||
"realtimeProgress" TEXT NOT NULL DEFAULT '',
|
||||
"createdById" TEXT,
|
||||
"createdByName" TEXT NOT NULL DEFAULT '',
|
||||
"updatedById" TEXT,
|
||||
"updatedByName" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FinishedProductProgressItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinishedProductProgressChangeLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"operation" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"actorName" TEXT NOT NULL,
|
||||
"changes" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "FinishedProductProgressChangeLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "FinishedProductProgressItem_contractNumber_barcode_key" ON "FinishedProductProgressItem"("contractNumber", "barcode");
|
||||
CREATE INDEX "FinishedProductProgressItem_itemNumber_idx" ON "FinishedProductProgressItem"("itemNumber");
|
||||
CREATE INDEX "FinishedProductProgressItem_supplier_idx" ON "FinishedProductProgressItem"("supplier");
|
||||
CREATE INDEX "FinishedProductProgressItem_brand_idx" ON "FinishedProductProgressItem"("brand");
|
||||
CREATE INDEX "FinishedProductProgressItem_currentNode_idx" ON "FinishedProductProgressItem"("currentNode");
|
||||
CREATE INDEX "FinishedProductProgressItem_orderDueDate_idx" ON "FinishedProductProgressItem"("orderDueDate");
|
||||
CREATE INDEX "FinishedProductProgressItem_updatedAt_idx" ON "FinishedProductProgressItem"("updatedAt");
|
||||
CREATE INDEX "FinishedProductProgressChangeLog_itemId_createdAt_idx" ON "FinishedProductProgressChangeLog"("itemId", "createdAt");
|
||||
CREATE INDEX "FinishedProductProgressChangeLog_createdAt_idx" ON "FinishedProductProgressChangeLog"("createdAt");
|
||||
|
||||
ALTER TABLE "FinishedProductProgressChangeLog"
|
||||
ADD CONSTRAINT "FinishedProductProgressChangeLog_itemId_fkey"
|
||||
FOREIGN KEY ("itemId") REFERENCES "FinishedProductProgressItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -1,2 +0,0 @@
|
||||
ALTER TABLE "InstallationDemoTool"
|
||||
ADD COLUMN "articulationConfig" JSONB;
|
||||
@ -1,22 +0,0 @@
|
||||
ALTER TABLE "StoreAccount"
|
||||
ADD COLUMN "subject" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "boundBankAccountNumberCiphertext" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE "StoreAccountStoreProfile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"storeName" TEXT NOT NULL,
|
||||
"platform" TEXT NOT NULL,
|
||||
"merchantNumber" TEXT NOT NULL DEFAULT '',
|
||||
"secondaryMerchantNumber" TEXT NOT NULL DEFAULT '',
|
||||
"merchantStoreId" TEXT NOT NULL DEFAULT '',
|
||||
"companyId" TEXT NOT NULL DEFAULT '',
|
||||
"createdById" TEXT,
|
||||
"updatedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "StoreAccountStoreProfile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "StoreAccountStoreProfile_storeName_platform_key" ON "StoreAccountStoreProfile"("storeName", "platform");
|
||||
CREATE INDEX "StoreAccountStoreProfile_storeName_idx" ON "StoreAccountStoreProfile"("storeName");
|
||||
CREATE INDEX "StoreAccountStoreProfile_platform_idx" ON "StoreAccountStoreProfile"("platform");
|
||||
@ -1,96 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScriptMonitorNetworkMode" AS ENUM ('INTRANET', 'INTERNET', 'AUTO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScriptMonitorConnectionKind" AS ENUM ('INTRANET', 'INTERNET');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScriptMonitorJobType" AS ENUM ('CRON', 'PM2', 'SYSTEMD', 'MANUAL', 'OTHER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScriptMonitorJobStatus" AS ENUM ('UNKNOWN', 'RUNNING', 'SUCCESS', 'FAILED', 'SILENT', 'DISABLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScriptMonitorRunStatus" AS ENUM ('RUNNING', 'SUCCESS', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ScriptMonitorAgent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"hostname" TEXT,
|
||||
"networkMode" "ScriptMonitorNetworkMode" NOT NULL DEFAULT 'AUTO',
|
||||
"lastConnectionKind" "ScriptMonitorConnectionKind",
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"tokenHint" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"os" TEXT,
|
||||
"agentVersion" TEXT,
|
||||
"reportIntervalSec" INTEGER NOT NULL DEFAULT 60,
|
||||
"lastSeenAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ScriptMonitorAgent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ScriptMonitorScript" (
|
||||
"id" TEXT NOT NULL,
|
||||
"agentId" TEXT NOT NULL,
|
||||
"externalKey" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"taskDescription" TEXT NOT NULL DEFAULT '',
|
||||
"jobType" "ScriptMonitorJobType" NOT NULL,
|
||||
"schedule" TEXT,
|
||||
"commandPreview" TEXT,
|
||||
"expectedIntervalSec" INTEGER,
|
||||
"status" "ScriptMonitorJobStatus" NOT NULL DEFAULT 'UNKNOWN',
|
||||
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastStartedAt" TIMESTAMP(3),
|
||||
"lastFinishedAt" TIMESTAMP(3),
|
||||
"lastDurationMs" INTEGER,
|
||||
"lastExitCode" INTEGER,
|
||||
"lastResultSummary" TEXT,
|
||||
"lastOutputTail" TEXT,
|
||||
"lastErrorTail" TEXT,
|
||||
"lastReportedAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ScriptMonitorScript_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ScriptMonitorRun" (
|
||||
"id" TEXT NOT NULL,
|
||||
"agentId" TEXT NOT NULL,
|
||||
"scriptId" TEXT NOT NULL,
|
||||
"externalRunId" TEXT NOT NULL,
|
||||
"status" "ScriptMonitorRunStatus" NOT NULL,
|
||||
"trigger" TEXT,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL,
|
||||
"finishedAt" TIMESTAMP(3),
|
||||
"durationMs" INTEGER,
|
||||
"exitCode" INTEGER,
|
||||
"resultSummary" TEXT,
|
||||
"outputTail" TEXT,
|
||||
"errorTail" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ScriptMonitorRun_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ScriptMonitorAgent_tokenHash_key" ON "ScriptMonitorAgent"("tokenHash");
|
||||
CREATE INDEX "ScriptMonitorAgent_isActive_lastSeenAt_idx" ON "ScriptMonitorAgent"("isActive", "lastSeenAt");
|
||||
CREATE UNIQUE INDEX "ScriptMonitorScript_agentId_externalKey_key" ON "ScriptMonitorScript"("agentId", "externalKey");
|
||||
CREATE INDEX "ScriptMonitorScript_agentId_status_idx" ON "ScriptMonitorScript"("agentId", "status");
|
||||
CREATE INDEX "ScriptMonitorScript_status_lastReportedAt_idx" ON "ScriptMonitorScript"("status", "lastReportedAt");
|
||||
CREATE UNIQUE INDEX "ScriptMonitorRun_agentId_externalRunId_key" ON "ScriptMonitorRun"("agentId", "externalRunId");
|
||||
CREATE INDEX "ScriptMonitorRun_scriptId_startedAt_idx" ON "ScriptMonitorRun"("scriptId", "startedAt" DESC);
|
||||
CREATE INDEX "ScriptMonitorRun_agentId_startedAt_idx" ON "ScriptMonitorRun"("agentId", "startedAt" DESC);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScriptMonitorScript" ADD CONSTRAINT "ScriptMonitorScript_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "ScriptMonitorAgent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "ScriptMonitorRun" ADD CONSTRAINT "ScriptMonitorRun_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "ScriptMonitorAgent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "ScriptMonitorRun" ADD CONSTRAINT "ScriptMonitorRun_scriptId_fkey" FOREIGN KEY ("scriptId") REFERENCES "ScriptMonitorScript"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -1,32 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScriptMonitorCommandStatus" AS ENUM ('PENDING', 'RUNNING', 'SUCCESS', 'FAILED', 'EXPIRED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ScriptMonitorScript"
|
||||
ADD COLUMN "canManualRun" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ScriptMonitorCommand" (
|
||||
"id" TEXT NOT NULL,
|
||||
"agentId" TEXT NOT NULL,
|
||||
"scriptId" TEXT NOT NULL,
|
||||
"status" "ScriptMonitorCommandStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"requestedById" TEXT,
|
||||
"requestedByName" TEXT,
|
||||
"requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"claimedAt" TIMESTAMP(3),
|
||||
"finishedAt" TIMESTAMP(3),
|
||||
"externalRunId" TEXT,
|
||||
"errorMessage" TEXT,
|
||||
|
||||
CONSTRAINT "ScriptMonitorCommand_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ScriptMonitorCommand_agentId_status_requestedAt_idx" ON "ScriptMonitorCommand"("agentId", "status", "requestedAt");
|
||||
CREATE INDEX "ScriptMonitorCommand_scriptId_requestedAt_idx" ON "ScriptMonitorCommand"("scriptId", "requestedAt" DESC);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScriptMonitorCommand" ADD CONSTRAINT "ScriptMonitorCommand_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "ScriptMonitorAgent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "ScriptMonitorCommand" ADD CONSTRAINT "ScriptMonitorCommand_scriptId_fkey" FOREIGN KEY ("scriptId") REFERENCES "ScriptMonitorScript"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -1,48 +0,0 @@
|
||||
CREATE TABLE "SupplierRecommendedProduct" (
|
||||
"id" TEXT NOT NULL,
|
||||
"recommendationDate" DATE,
|
||||
"supplier" TEXT NOT NULL DEFAULT '',
|
||||
"purchaser" TEXT NOT NULL DEFAULT '',
|
||||
"productCode" TEXT NOT NULL DEFAULT '',
|
||||
"imageUrl" TEXT,
|
||||
"sampleRequest" TEXT NOT NULL DEFAULT '',
|
||||
"dimensions" TEXT NOT NULL DEFAULT '',
|
||||
"price" TEXT NOT NULL DEFAULT '',
|
||||
"material" TEXT NOT NULL DEFAULT '',
|
||||
"boardGrade" TEXT NOT NULL DEFAULT '',
|
||||
"weight" TEXT NOT NULL DEFAULT '',
|
||||
"orderQuantity" INTEGER NOT NULL DEFAULT 0,
|
||||
"productionCycle" TEXT NOT NULL DEFAULT '',
|
||||
"shippingMode" TEXT NOT NULL DEFAULT '',
|
||||
"dropShippingFee" TEXT NOT NULL DEFAULT '',
|
||||
"createdById" TEXT,
|
||||
"createdByName" TEXT NOT NULL DEFAULT '',
|
||||
"updatedById" TEXT,
|
||||
"updatedByName" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SupplierRecommendedProduct_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "SupplierRecommendedProductChangeLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"operation" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"actorName" TEXT NOT NULL,
|
||||
"changes" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SupplierRecommendedProductChangeLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "SupplierRecommendedProduct_recommendationDate_idx" ON "SupplierRecommendedProduct"("recommendationDate");
|
||||
CREATE INDEX "SupplierRecommendedProduct_supplier_idx" ON "SupplierRecommendedProduct"("supplier");
|
||||
CREATE INDEX "SupplierRecommendedProduct_purchaser_idx" ON "SupplierRecommendedProduct"("purchaser");
|
||||
CREATE INDEX "SupplierRecommendedProduct_productCode_idx" ON "SupplierRecommendedProduct"("productCode");
|
||||
CREATE INDEX "SupplierRecommendedProduct_shippingMode_idx" ON "SupplierRecommendedProduct"("shippingMode");
|
||||
CREATE INDEX "SupplierRecommendedProductChangeLog_itemId_createdAt_idx" ON "SupplierRecommendedProductChangeLog"("itemId", "createdAt");
|
||||
CREATE INDEX "SupplierRecommendedProductChangeLog_createdAt_idx" ON "SupplierRecommendedProductChangeLog"("createdAt");
|
||||
|
||||
ALTER TABLE "SupplierRecommendedProductChangeLog" ADD CONSTRAINT "SupplierRecommendedProductChangeLog_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "SupplierRecommendedProduct"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -1,8 +0,0 @@
|
||||
ALTER TABLE "FinishedProductProgressItem" RENAME COLUMN "realtimeProgress" TO "remarks";
|
||||
|
||||
ALTER TABLE "FinishedProductProgressItem"
|
||||
ADD COLUMN "realtimeDate" DATE,
|
||||
ADD COLUMN "deliveryQuantity" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE "FinishedProductProgressItem"
|
||||
SET "deliveryQuantity" = GREATEST("orderedQuantity" - "inTransitQuantity", 0);
|
||||
@ -1,26 +0,0 @@
|
||||
ALTER TABLE "FinishedProductProgressItem"
|
||||
ADD COLUMN "contractDateNumber" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
WITH contract_first AS (
|
||||
SELECT
|
||||
"contractNumber",
|
||||
MIN("createdAt") AS "firstCreatedAt",
|
||||
to_char(MIN("createdAt") + interval '8 hours', 'YYMMDD') AS "datePrefix"
|
||||
FROM "FinishedProductProgressItem"
|
||||
GROUP BY "contractNumber"
|
||||
), numbered AS (
|
||||
SELECT
|
||||
"contractNumber",
|
||||
"datePrefix" || lpad(row_number() OVER (
|
||||
PARTITION BY "datePrefix"
|
||||
ORDER BY "firstCreatedAt" ASC, "contractNumber" ASC
|
||||
)::text, 3, '0') AS "contractDateNumber"
|
||||
FROM contract_first
|
||||
)
|
||||
UPDATE "FinishedProductProgressItem" AS item
|
||||
SET "contractDateNumber" = numbered."contractDateNumber"
|
||||
FROM numbered
|
||||
WHERE numbered."contractNumber" = item."contractNumber";
|
||||
|
||||
CREATE INDEX "FinishedProductProgressItem_contractDateNumber_idx"
|
||||
ON "FinishedProductProgressItem"("contractDateNumber");
|
||||
@ -1,12 +0,0 @@
|
||||
ALTER TABLE "FinishedProductProgressItem"
|
||||
ALTER COLUMN "barcode" DROP NOT NULL;
|
||||
|
||||
UPDATE "FinishedProductProgressItem"
|
||||
SET "barcode" = NULL
|
||||
WHERE "barcode" = '';
|
||||
|
||||
DROP INDEX IF EXISTS "FinishedProductProgressItem_contractNumber_barcode_key";
|
||||
|
||||
CREATE UNIQUE INDEX "FinishedProductProgressItem_contractNumber_barcode_nonempty_key"
|
||||
ON "FinishedProductProgressItem"("contractNumber", "barcode")
|
||||
WHERE "barcode" IS NOT NULL AND "barcode" <> '';
|
||||
@ -1,51 +0,0 @@
|
||||
CREATE TABLE "FinishedProductDeliveryRecord" (
|
||||
"id" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"deliveryDate" DATE NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"createdById" TEXT,
|
||||
"createdByName" TEXT NOT NULL DEFAULT '',
|
||||
"updatedById" TEXT,
|
||||
"updatedByName" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FinishedProductDeliveryRecord_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "FinishedProductDeliveryRecord_itemId_status_deliveryDate_idx"
|
||||
ON "FinishedProductDeliveryRecord"("itemId", "status", "deliveryDate");
|
||||
|
||||
CREATE INDEX "FinishedProductDeliveryRecord_itemId_createdAt_idx"
|
||||
ON "FinishedProductDeliveryRecord"("itemId", "createdAt");
|
||||
|
||||
ALTER TABLE "FinishedProductDeliveryRecord"
|
||||
ADD CONSTRAINT "FinishedProductDeliveryRecord_itemId_fkey"
|
||||
FOREIGN KEY ("itemId") REFERENCES "FinishedProductProgressItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO "FinishedProductDeliveryRecord" (
|
||||
"id",
|
||||
"itemId",
|
||||
"deliveryDate",
|
||||
"quantity",
|
||||
"createdById",
|
||||
"createdByName",
|
||||
"updatedById",
|
||||
"updatedByName",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
)
|
||||
SELECT
|
||||
'delivery-' || substr(md5(item."id" || ':' || clock_timestamp()::text), 1, 24),
|
||||
item."id",
|
||||
COALESCE(item."realtimeDate", (item."createdAt" AT TIME ZONE 'Asia/Shanghai')::date),
|
||||
item."deliveryQuantity",
|
||||
item."createdById",
|
||||
item."createdByName",
|
||||
item."updatedById",
|
||||
item."updatedByName",
|
||||
item."createdAt",
|
||||
item."updatedAt"
|
||||
FROM "FinishedProductProgressItem" AS item
|
||||
WHERE item."deliveryQuantity" > 0;
|
||||
@ -1,7 +0,0 @@
|
||||
ALTER TABLE "FinishedProductProgressItem"
|
||||
ADD COLUMN "completedAt" TIMESTAMP(3);
|
||||
|
||||
UPDATE "FinishedProductProgressItem"
|
||||
SET "completedAt" = COALESCE("realtimeDate"::timestamp, "updatedAt")
|
||||
WHERE "orderedQuantity" > 0
|
||||
AND "deliveryQuantity" >= "orderedQuantity";
|
||||
@ -1,69 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE "FinishedProductProgressItem"
|
||||
ADD COLUMN "firstDueDate" DATE;
|
||||
|
||||
ALTER TABLE "FinishedProductProgressChangeLog"
|
||||
ADD COLUMN "changeReason" TEXT;
|
||||
|
||||
UPDATE "FinishedProductProgressItem"
|
||||
SET "firstDueDate" = "orderDueDate"
|
||||
WHERE "firstDueDate" IS NULL
|
||||
AND "orderDueDate" IS NOT NULL;
|
||||
|
||||
WITH contract_groups AS (
|
||||
SELECT
|
||||
"contractNumber",
|
||||
"contractDateNumber",
|
||||
MIN("createdAt") AS "firstCreatedAt",
|
||||
COUNT(*) OVER (PARTITION BY "contractDateNumber") AS "dateNumberContractCount",
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY "contractDateNumber"
|
||||
ORDER BY MIN("createdAt"), "contractNumber"
|
||||
) AS "contractRank"
|
||||
FROM "FinishedProductProgressItem"
|
||||
WHERE trim("contractDateNumber") <> ''
|
||||
GROUP BY "contractNumber", "contractDateNumber"
|
||||
), collision_targets AS (
|
||||
SELECT *
|
||||
FROM contract_groups
|
||||
WHERE "dateNumberContractCount" > 1
|
||||
AND "contractRank" > 1
|
||||
), prefix_max AS (
|
||||
SELECT
|
||||
left("contractDateNumber", 6) AS "prefix",
|
||||
COALESCE(MAX(
|
||||
CASE
|
||||
WHEN right("contractDateNumber", 3) ~ '^[0-9]{3}$' THEN right("contractDateNumber", 3)::integer
|
||||
ELSE 0
|
||||
END
|
||||
), 0) AS "maxSequence"
|
||||
FROM "FinishedProductProgressItem"
|
||||
WHERE trim("contractDateNumber") <> ''
|
||||
GROUP BY left("contractDateNumber", 6)
|
||||
), assignments AS (
|
||||
SELECT
|
||||
target."contractNumber",
|
||||
left(target."contractDateNumber", 6) AS "prefix",
|
||||
prefix_max."maxSequence" + ROW_NUMBER() OVER (
|
||||
PARTITION BY left(target."contractDateNumber", 6)
|
||||
ORDER BY target."firstCreatedAt", target."contractNumber"
|
||||
) AS "newSequence"
|
||||
FROM collision_targets AS target
|
||||
JOIN prefix_max
|
||||
ON prefix_max."prefix" = left(target."contractDateNumber", 6)
|
||||
)
|
||||
UPDATE "FinishedProductProgressItem" AS item
|
||||
SET "contractDateNumber" = assignments."prefix" || lpad(assignments."newSequence"::text, 3, '0')
|
||||
FROM assignments
|
||||
WHERE item."contractNumber" = assignments."contractNumber";
|
||||
|
||||
UPDATE "FinishedProductProgressItem"
|
||||
SET "currentNode" = CASE
|
||||
WHEN trim("contractNumber") = '' THEN '合同签订'
|
||||
WHEN "orderedQuantity" > 0 AND "deliveryQuantity" >= "orderedQuantity" THEN '已完结'
|
||||
WHEN "deliveryQuantity" > 0 THEN '送货中'
|
||||
ELSE '采购在途'
|
||||
END;
|
||||
|
||||
COMMIT;
|
||||
@ -1,2 +0,0 @@
|
||||
ALTER TABLE "FinishedProductProgressItem"
|
||||
ADD COLUMN "followUpOwner" TEXT NOT NULL DEFAULT '';
|
||||
@ -1,5 +0,0 @@
|
||||
ALTER TABLE "SupplierRecommendedProduct"
|
||||
ADD COLUMN "category" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX "SupplierRecommendedProduct_category_idx"
|
||||
ON "SupplierRecommendedProduct"("category");
|
||||
@ -1,8 +0,0 @@
|
||||
ALTER TABLE "SupplierRecommendedProduct"
|
||||
ADD COLUMN "selectionStatus" TEXT NOT NULL DEFAULT 'UNSELECTED',
|
||||
ADD COLUMN "selectedById" TEXT,
|
||||
ADD COLUMN "selectedByName" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "selectedAt" TIMESTAMP(3);
|
||||
|
||||
CREATE INDEX "SupplierRecommendedProduct_selectionStatus_idx"
|
||||
ON "SupplierRecommendedProduct"("selectionStatus");
|
||||
@ -1,18 +0,0 @@
|
||||
ALTER TABLE "FinishedProductProgressItem"
|
||||
ADD COLUMN "orderPlacedDate" DATE,
|
||||
ADD COLUMN "orderSequenceNo" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Preserve the legacy date-number column for compatibility, while deriving a
|
||||
-- real date for rows created before the order identity redesign. Legacy values
|
||||
-- use YYMMDDNNN (for example 260829001 -> 2026-08-29).
|
||||
UPDATE "FinishedProductProgressItem"
|
||||
SET "orderPlacedDate" = to_date('20' || substring("contractDateNumber" from 1 for 6), 'YYYYMMDD')
|
||||
WHERE "orderPlacedDate" IS NULL
|
||||
AND "contractDateNumber" ~ '^[0-9]{9}$'
|
||||
AND substring("contractDateNumber" from 3 for 2)::int BETWEEN 1 AND 12
|
||||
AND substring("contractDateNumber" from 5 for 2)::int BETWEEN 1 AND 31
|
||||
AND to_char(to_date('20' || substring("contractDateNumber" from 1 for 6), 'YYYYMMDD'), 'YYYYMMDD')
|
||||
= '20' || substring("contractDateNumber" from 1 for 6);
|
||||
|
||||
CREATE INDEX "FinishedProductProgressItem_orderSequenceNo_idx"
|
||||
ON "FinishedProductProgressItem"("orderSequenceNo");
|
||||
@ -1,14 +0,0 @@
|
||||
-- The legacy index only allowed one row per contract and barcode. That
|
||||
-- conflicts with the order identity redesign, where order date separates
|
||||
-- otherwise identical orders and may be left blank for later maintenance.
|
||||
DROP INDEX IF EXISTS "FinishedProductProgressItem_contractNumber_barcode_nonempty_key";
|
||||
DROP INDEX IF EXISTS "FinishedProductProgressItem_contractNumber_barcode_key";
|
||||
|
||||
-- Dated orders are unique by the complete order identity. Undated rows are
|
||||
-- intentionally left outside the database constraint so they can be imported
|
||||
-- before the order date is maintained.
|
||||
CREATE UNIQUE INDEX "FinishedProductProgressItem_order_identity_nonempty_key"
|
||||
ON "FinishedProductProgressItem"("contractNumber", "itemNumber", "barcode", "orderPlacedDate")
|
||||
WHERE "barcode" IS NOT NULL
|
||||
AND "barcode" <> ''
|
||||
AND "orderPlacedDate" IS NOT NULL;
|
||||
@ -1,4 +0,0 @@
|
||||
-- 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");
|
||||
@ -1,49 +0,0 @@
|
||||
BEGIN;
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
SET LOCAL statement_timeout = '30s';
|
||||
|
||||
CREATE TABLE public."StoreAccountPasswordAccessAudit" (
|
||||
"requestId" UUID PRIMARY KEY,
|
||||
"requesterId" VARCHAR(128),
|
||||
"serviceId" VARCHAR(64),
|
||||
"accountId" VARCHAR(128),
|
||||
purpose VARCHAR(8) NOT NULL CHECK (purpose IN ('view', 'copy', 'unknown')),
|
||||
status INTEGER NOT NULL CHECK (status IN (200, 403, 404, 429, 503)),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
CREATE INDEX "StoreAccountPasswordAccessAudit_serviceId_createdAt_idx"
|
||||
ON public."StoreAccountPasswordAccessAudit" ("serviceId", "createdAt");
|
||||
CREATE INDEX "StoreAccountPasswordAccessAudit_accountId_createdAt_idx"
|
||||
ON public."StoreAccountPasswordAccessAudit" ("accountId", "createdAt");
|
||||
REVOKE ALL ON public."StoreAccountPasswordAccessAudit" FROM PUBLIC;
|
||||
|
||||
DO $migration$
|
||||
DECLARE
|
||||
original_definition TEXT;
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'store_account_reader') THEN
|
||||
REVOKE ALL ON public."StoreAccountPasswordAccessAudit" FROM store_account_reader;
|
||||
END IF;
|
||||
-- The external view is provisioned separately by the DBA. Do not create it or
|
||||
-- broaden its grants in environments that have not opted into this integration.
|
||||
IF to_regclass('external_store_accounts.accounts_v1') IS NULL THEN RETURN; END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'external_store_accounts' AND table_name = 'accounts_v1'
|
||||
AND column_name = 'passwordConfigured') THEN
|
||||
RAISE EXCEPTION 'External account contract needs preflight review';
|
||||
END IF;
|
||||
original_definition := rtrim(pg_get_viewdef('external_store_accounts.accounts_v1'::regclass, true), E';\n\r ');
|
||||
-- Keep the original query, projection, row filters, duplicates (if any), owner
|
||||
-- and ACL. EXISTS does not multiply rows. No secret is exposed in this view.
|
||||
EXECUTE format($view$
|
||||
CREATE OR REPLACE VIEW external_store_accounts.accounts_v1 AS
|
||||
SELECT original.*, EXISTS (
|
||||
SELECT 1 FROM public."StoreAccount" credential
|
||||
WHERE credential.id = original.id
|
||||
AND credential."passwordCiphertext" ~ '^v1:[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{2,}$'
|
||||
AND length(split_part(credential."passwordCiphertext", '.', 3)) %% 4 <> 1
|
||||
)::boolean AS "passwordConfigured"
|
||||
FROM (%s) original
|
||||
$view$, original_definition);
|
||||
END $migration$;
|
||||
COMMIT;
|
||||
@ -1,92 +0,0 @@
|
||||
BEGIN;
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
SET LOCAL statement_timeout = '30s';
|
||||
|
||||
DO $preflight$
|
||||
DECLARE
|
||||
actual_columns TEXT[];
|
||||
expected_columns CONSTANT TEXT[] := ARRAY[
|
||||
'id', 'storeName', 'operationOwner', 'operationDepartmentHead',
|
||||
'operationDirector', 'profitCenter', 'subject', 'accountType', 'account',
|
||||
'boundPhone', 'verificationContact', 'jstOrderService', 'accountGroupName',
|
||||
'createdAt', 'updatedAt', 'passwordConfigured'
|
||||
];
|
||||
BEGIN
|
||||
IF to_regclass('external_store_accounts.accounts_v1') IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
SELECT array_agg(column_name ORDER BY ordinal_position)
|
||||
INTO actual_columns
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'external_store_accounts'
|
||||
AND table_name = 'accounts_v1';
|
||||
|
||||
IF actual_columns IS DISTINCT FROM expected_columns THEN
|
||||
RAISE EXCEPTION 'External account contract needs preflight review';
|
||||
END IF;
|
||||
END $preflight$;
|
||||
|
||||
DO $migration$
|
||||
DECLARE
|
||||
original_owner OID;
|
||||
original_acl ACLITEM[];
|
||||
BEGIN
|
||||
IF to_regclass('external_store_accounts.accounts_v1') IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
SELECT relowner, relacl
|
||||
INTO original_owner, original_acl
|
||||
FROM pg_class
|
||||
WHERE oid = 'external_store_accounts.accounts_v1'::regclass;
|
||||
|
||||
CREATE TEMP TABLE store_account_view_rows_before ON COMMIT DROP AS
|
||||
SELECT to_jsonb(account_row) - 'passwordConfigured' AS row_value
|
||||
FROM external_store_accounts.accounts_v1 account_row;
|
||||
|
||||
CREATE OR REPLACE VIEW external_store_accounts.accounts_v1 AS
|
||||
SELECT
|
||||
source.id,
|
||||
source."storeName",
|
||||
source."operationOwner",
|
||||
source."operationDepartmentHead",
|
||||
source."operationDirector",
|
||||
source."profitCenter",
|
||||
source.subject,
|
||||
source."accountType",
|
||||
source.account,
|
||||
source."boundPhone",
|
||||
source."verificationContact",
|
||||
source."jstOrderService",
|
||||
source."accountGroupName",
|
||||
source."createdAt",
|
||||
source."updatedAt",
|
||||
(source."passwordCiphertext" <> '')::boolean AS "passwordConfigured"
|
||||
FROM public."StoreAccount" source;
|
||||
|
||||
IF EXISTS (
|
||||
(SELECT row_value FROM store_account_view_rows_before
|
||||
EXCEPT ALL
|
||||
SELECT to_jsonb(account_row) - 'passwordConfigured'
|
||||
FROM external_store_accounts.accounts_v1 account_row)
|
||||
UNION ALL
|
||||
(SELECT to_jsonb(account_row) - 'passwordConfigured'
|
||||
FROM external_store_accounts.accounts_v1 account_row
|
||||
EXCEPT ALL
|
||||
SELECT row_value FROM store_account_view_rows_before)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'External account rows changed while fixing password status';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_class
|
||||
WHERE oid = 'external_store_accounts.accounts_v1'::regclass
|
||||
AND (relowner <> original_owner OR relacl IS DISTINCT FROM original_acl)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'External account owner or ACL changed';
|
||||
END IF;
|
||||
END $migration$;
|
||||
|
||||
COMMIT;
|
||||
@ -1,7 +0,0 @@
|
||||
-- Persist the Jianshang (JST public available) stock on the archive main table
|
||||
-- so list filters, page rows and exports all read one source of truth.
|
||||
ALTER TABLE "SupplyChainProductArchive"
|
||||
ADD COLUMN "stockJianshang" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX "SupplyChainProductArchive_stockJianshang_idx"
|
||||
ON "SupplyChainProductArchive"("stockJianshang");
|
||||
@ -1,43 +0,0 @@
|
||||
-- Product archive period-aware sales facts and sync audit.
|
||||
CREATE TABLE "SupplyChainProductArchiveSalesPerformanceDaily" (
|
||||
"barcode" VARCHAR(100) NOT NULL,
|
||||
"statDate" DATE NOT NULL,
|
||||
"shipAmount" DECIMAL(24,4) NOT NULL,
|
||||
"returnAmount" DECIMAL(24,4) NOT NULL,
|
||||
"realPerformance" DECIMAL(24,4) NOT NULL,
|
||||
"shipQty" BIGINT NOT NULL,
|
||||
"returnQty" BIGINT NOT NULL,
|
||||
"clearSalesCost" DECIMAL(24,4) NOT NULL,
|
||||
"sendFreight" DECIMAL(24,4) NOT NULL,
|
||||
"platformCommission" DECIMAL(24,4) NOT NULL,
|
||||
"xyCost" DECIMAL(24,4) NOT NULL,
|
||||
"afterSaleAmount" DECIMAL(24,4) NOT NULL,
|
||||
"pushFeeTotal" DECIMAL(24,4) NOT NULL,
|
||||
"marketingFeeTotal" DECIMAL(24,4) NOT NULL,
|
||||
"advertisementFeeTotal" DECIMAL(24,4) NOT NULL,
|
||||
"sharedWarehousingSum" DECIMAL(24,4) NOT NULL,
|
||||
"otherFee" DECIMAL(24,4) NOT NULL,
|
||||
"operatingFee" DECIMAL(24,4) NOT NULL,
|
||||
"sourceRowCount" INTEGER NOT NULL,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "SupplyChainProductArchiveSalesPerformanceDaily_pkey" PRIMARY KEY ("barcode", "statDate")
|
||||
);
|
||||
CREATE INDEX "SupplyChainProductArchiveSalesPerformanceDaily_statDate_idx" ON "SupplyChainProductArchiveSalesPerformanceDaily"("statDate");
|
||||
CREATE INDEX "SupplyChainProductArchiveSalesPerformanceDaily_syncedAt_idx" ON "SupplyChainProductArchiveSalesPerformanceDaily"("syncedAt");
|
||||
|
||||
CREATE TABLE "SupplyChainProductArchiveSalesSyncRun" (
|
||||
"id" TEXT NOT NULL,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"status" VARCHAR(24) NOT NULL,
|
||||
"rangeStart" DATE NOT NULL,
|
||||
"rangeEnd" DATE NOT NULL,
|
||||
"sourceLatestDate" DATE,
|
||||
"rowsRead" INTEGER NOT NULL DEFAULT 0,
|
||||
"rowsWritten" INTEGER NOT NULL DEFAULT 0,
|
||||
"unmatchedBarcodes" INTEGER NOT NULL DEFAULT 0,
|
||||
"errorMessage" TEXT,
|
||||
CONSTRAINT "SupplyChainProductArchiveSalesSyncRun_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX "SupplyChainProductArchiveSalesSyncRun_startedAt_idx" ON "SupplyChainProductArchiveSalesSyncRun"("startedAt");
|
||||
CREATE INDEX "SupplyChainProductArchiveSalesSyncRun_status_startedAt_idx" ON "SupplyChainProductArchiveSalesSyncRun"("status", "startedAt");
|
||||
@ -1,19 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "HomeQuickLink" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"url" TEXT NOT NULL,
|
||||
"iconKey" TEXT NOT NULL DEFAULT 'link',
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdBy" TEXT NOT NULL,
|
||||
"updatedBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HomeQuickLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "HomeQuickLink_enabled_sortOrder_idx" ON "HomeQuickLink"("enabled", "sortOrder");
|
||||
@ -1,6 +0,0 @@
|
||||
-- Keep all existing barcode/day facts and the mainline shipAmount definition.
|
||||
-- NULL salesAmount explicitly marks history requiring a source backfill.
|
||||
ALTER TABLE "SupplyChainProductArchiveSalesPerformanceDaily"
|
||||
ADD COLUMN "skuOperator" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "unitCost" DECIMAL(24,4),
|
||||
ADD COLUMN "salesAmount" DECIMAL(24,4);
|
||||
@ -1,19 +0,0 @@
|
||||
-- Operations-maintained checklist per dropship (DF) SPU on the 1688 dropship
|
||||
-- issues page. Read-only columns always come live from the product archive;
|
||||
-- only the two operations-maintained fields live here.
|
||||
CREATE TABLE "SupplyChainDropshipIssue" (
|
||||
"id" TEXT NOT NULL,
|
||||
"spu" VARCHAR(120) NOT NULL,
|
||||
"invoiceType" VARCHAR(16) NOT NULL DEFAULT '',
|
||||
"returnAddress" TEXT NOT NULL DEFAULT '',
|
||||
"updatedByName" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SupplyChainDropshipIssue_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "SupplyChainDropshipIssue_spu_key" ON "SupplyChainDropshipIssue"("spu");
|
||||
CREATE INDEX "SupplyChainDropshipIssue_updatedAt_idx" ON "SupplyChainDropshipIssue"("updatedAt");
|
||||
|
||||
ALTER TYPE "OperationLogAction" ADD VALUE IF NOT EXISTS 'SUPPLY_CHAIN_DROPSHIP_ISSUE_WRITE';
|
||||
@ -1,15 +0,0 @@
|
||||
ALTER TABLE "FinishedProductProgressItem"
|
||||
ADD COLUMN "sourceOpeningCode" TEXT,
|
||||
ADD COLUMN "sourceProductId" TEXT,
|
||||
ADD COLUMN "sourceSkuId" TEXT,
|
||||
ADD COLUMN "sourceGroupKey" TEXT,
|
||||
ADD COLUMN "sourceSupplierCode" TEXT,
|
||||
ADD COLUMN "sourceImportedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "supplierCode" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE UNIQUE INDEX "FinishedProductProgressItem_factory_source_key"
|
||||
ON "FinishedProductProgressItem" ("sourceOpeningCode", "sourceProductId", "sourceSkuId");
|
||||
CREATE INDEX "FinishedProductProgressItem_sourceGroupKey_idx"
|
||||
ON "FinishedProductProgressItem" ("sourceGroupKey");
|
||||
CREATE INDEX "FinishedProductProgressItem_sourceImportedAt_idx"
|
||||
ON "FinishedProductProgressItem" ("sourceImportedAt");
|
||||
@ -1,4 +0,0 @@
|
||||
-- 1688 dropship issues page: operations now maintain whether the SPU ships
|
||||
-- assembled (整装, drawing not required) or knocked down (拆装, drawing required).
|
||||
ALTER TABLE "SupplyChainDropshipIssue"
|
||||
ADD COLUMN "assemblyType" VARCHAR(16) NOT NULL DEFAULT '';
|
||||
@ -1,21 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "BomCostingAgentSkuEstimate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"caseId" TEXT NOT NULL,
|
||||
"skuId" TEXT NOT NULL,
|
||||
"externalSkuId" TEXT NOT NULL DEFAULT '',
|
||||
"engineVersion" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"estimate" JSONB NOT NULL,
|
||||
"usage" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "BomCostingAgentSkuEstimate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "BomCostingAgentSkuEstimate_skuId_engineVersion_key" ON "BomCostingAgentSkuEstimate"("skuId", "engineVersion");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "BomCostingAgentSkuEstimate_caseId_idx" ON "BomCostingAgentSkuEstimate"("caseId");
|
||||
@ -1,22 +0,0 @@
|
||||
CREATE TABLE "SupplyChainManagementOwnerConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"spu" TEXT NOT NULL,
|
||||
"itemNumber" TEXT NOT NULL,
|
||||
"productCode" TEXT NOT NULL,
|
||||
"operationOwner" TEXT NOT NULL DEFAULT '',
|
||||
"departmentOwner" TEXT NOT NULL DEFAULT '',
|
||||
"generalOwner" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SupplyChainManagementOwnerConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "SupplyChainManagementOwnerConfig_spu_itemNumber_productCode_key"
|
||||
ON "SupplyChainManagementOwnerConfig"("spu", "itemNumber", "productCode");
|
||||
CREATE INDEX "SupplyChainManagementOwnerConfig_spu_idx"
|
||||
ON "SupplyChainManagementOwnerConfig"("spu");
|
||||
CREATE INDEX "SupplyChainManagementOwnerConfig_itemNumber_idx"
|
||||
ON "SupplyChainManagementOwnerConfig"("itemNumber");
|
||||
CREATE INDEX "SupplyChainManagementOwnerConfig_productCode_idx"
|
||||
ON "SupplyChainManagementOwnerConfig"("productCode");
|
||||
@ -1,9 +0,0 @@
|
||||
DROP INDEX IF EXISTS "SupplyChainManagementOwnerConfig_spu_itemNumber_productCode_key";
|
||||
DROP INDEX IF EXISTS "SupplyChainManagementOwnerConfig_spu_idx";
|
||||
DROP INDEX IF EXISTS "SupplyChainManagementOwnerConfig_itemNumber_idx";
|
||||
DROP INDEX IF EXISTS "SupplyChainManagementOwnerConfig_productCode_idx";
|
||||
|
||||
ALTER TABLE "SupplyChainManagementOwnerConfig"
|
||||
DROP COLUMN IF EXISTS "spu",
|
||||
DROP COLUMN IF EXISTS "itemNumber",
|
||||
DROP COLUMN IF EXISTS "productCode";
|
||||
@ -1,8 +0,0 @@
|
||||
ALTER TABLE "Department"
|
||||
ADD COLUMN "directoryManagerDingtalkUserIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
ALTER TABLE "EmployeeDirectory"
|
||||
ADD COLUMN "managerDingtalkUserId" TEXT;
|
||||
|
||||
CREATE INDEX "EmployeeDirectory_managerDingtalkUserId_idx"
|
||||
ON "EmployeeDirectory"("managerDingtalkUserId");
|
||||
@ -1,10 +0,0 @@
|
||||
ALTER TABLE "SupplyChainManagementOwnerConfig"
|
||||
ADD COLUMN "sourceEmployeeId" TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX "SupplyChainManagementOwnerConfig_sourceEmployeeId_key"
|
||||
ON "SupplyChainManagementOwnerConfig"("sourceEmployeeId");
|
||||
|
||||
ALTER TABLE "SupplyChainManagementOwnerConfig"
|
||||
ADD CONSTRAINT "SupplyChainManagementOwnerConfig_sourceEmployeeId_fkey"
|
||||
FOREIGN KEY ("sourceEmployeeId") REFERENCES "EmployeeDirectory"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -1,6 +0,0 @@
|
||||
ALTER TABLE "SupplyChainManagementOwnerConfig"
|
||||
ADD COLUMN "manualOverride" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "isHidden" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX "SupplyChainManagementOwnerConfig_isHidden_updatedAt_idx"
|
||||
ON "SupplyChainManagementOwnerConfig"("isHidden", "updatedAt");
|
||||
@ -1,3 +0,0 @@
|
||||
ALTER TABLE "SupplyChainManagementOwnerConfig"
|
||||
ADD COLUMN "department" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "profitCenter" TEXT NOT NULL DEFAULT '';
|
||||
@ -1,42 +0,0 @@
|
||||
CREATE TABLE "Dropship1688SourcingTask" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sourceArchiveId" TEXT NOT NULL,
|
||||
"sourceSpu" TEXT NOT NULL DEFAULT '',
|
||||
"sourceBarcode" VARCHAR(100) NOT NULL,
|
||||
"sourceCostPrice" DECIMAL(24,4),
|
||||
"sourceSkuOperator" TEXT NOT NULL DEFAULT '',
|
||||
"sourceDateFrom" DATE NOT NULL,
|
||||
"sourceDateTo" DATE NOT NULL,
|
||||
"purchaserUserId" TEXT NOT NULL,
|
||||
"purchaserName" TEXT NOT NULL,
|
||||
"supplierName" TEXT NOT NULL DEFAULT '',
|
||||
"quoted_price_tax_included" DECIMAL(24,4),
|
||||
"invoiceTaxPoint" DECIMAL(8,4),
|
||||
"sourcingMode" TEXT NOT NULL DEFAULT '',
|
||||
"jianshangBarcode" TEXT NOT NULL DEFAULT '',
|
||||
"acceptedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Dropship1688SourcingTask_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Dropship1688SourcingTask_sourceBarcode_purchaserUserId_key"
|
||||
ON "Dropship1688SourcingTask"("sourceBarcode", "purchaserUserId");
|
||||
|
||||
CREATE INDEX "Dropship1688SourcingTask_sourceArchiveId_idx"
|
||||
ON "Dropship1688SourcingTask"("sourceArchiveId");
|
||||
|
||||
CREATE INDEX "Dropship1688SourcingTask_purchaserName_idx"
|
||||
ON "Dropship1688SourcingTask"("purchaserName");
|
||||
|
||||
CREATE INDEX "Dropship1688SourcingTask_sourceSpu_idx"
|
||||
ON "Dropship1688SourcingTask"("sourceSpu");
|
||||
|
||||
CREATE INDEX "Dropship1688SourcingTask_sourceSkuOperator_idx"
|
||||
ON "Dropship1688SourcingTask"("sourceSkuOperator");
|
||||
|
||||
CREATE INDEX "Dropship1688SourcingTask_purchaserUserId_acceptedAt_idx"
|
||||
ON "Dropship1688SourcingTask"("purchaserUserId", "acceptedAt");
|
||||
|
||||
CREATE INDEX "Dropship1688SourcingTask_acceptedAt_idx"
|
||||
ON "Dropship1688SourcingTask"("acceptedAt");
|
||||
@ -1,3 +0,0 @@
|
||||
-- Store the quantity-one freight estimate separately from the existing total freight fact.
|
||||
ALTER TABLE "SupplyChainProductArchiveSalesPerformanceDaily"
|
||||
ADD COLUMN "estimatedFreight" DECIMAL(24,4);
|
||||
@ -1,32 +0,0 @@
|
||||
ALTER TABLE "SupplyChainManagementOwnerConfig"
|
||||
ADD COLUMN "secondaryDepartment" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
WITH RECURSIVE department_tree AS (
|
||||
SELECT "id", "name", "parentId", 0 AS depth
|
||||
FROM "Department"
|
||||
WHERE "externalId" = '1'
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT child."id", child."name", child."parentId", parent.depth + 1
|
||||
FROM "Department" child
|
||||
INNER JOIN department_tree parent ON child."parentId" = parent."id"
|
||||
), employee_ancestors AS (
|
||||
SELECT employee."id" AS "employeeId", department."id" AS "departmentId", department."parentId"
|
||||
FROM "EmployeeDirectory" employee
|
||||
INNER JOIN "Department" department
|
||||
ON department."externalId" = employee."primaryDepartmentExternalId"
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT ancestor."employeeId", parent."id", parent."parentId"
|
||||
FROM employee_ancestors ancestor
|
||||
INNER JOIN "Department" parent ON parent."id" = ancestor."parentId"
|
||||
)
|
||||
UPDATE "SupplyChainManagementOwnerConfig" config
|
||||
SET "secondaryDepartment" = department_tree."name"
|
||||
FROM employee_ancestors
|
||||
INNER JOIN department_tree
|
||||
ON department_tree."id" = employee_ancestors."departmentId"
|
||||
AND department_tree.depth = 3
|
||||
WHERE config."sourceEmployeeId" = employee_ancestors."employeeId";
|
||||
@ -1,67 +0,0 @@
|
||||
ALTER TABLE "SupplyChainProductArchive"
|
||||
ADD COLUMN "sabcRating" VARCHAR(1) NOT NULL DEFAULT '',
|
||||
ADD COLUMN "sabcUpdatedAt" TIMESTAMP(3);
|
||||
|
||||
CREATE TABLE "SupplyChainProductArchiveSabcResult" (
|
||||
"id" TEXT NOT NULL,
|
||||
"archiveId" TEXT NOT NULL,
|
||||
"barcode" VARCHAR(100) NOT NULL,
|
||||
"periodStart" DATE NOT NULL,
|
||||
"periodEnd" DATE NOT NULL,
|
||||
"eligible" BOOLEAN NOT NULL,
|
||||
"sabcRating" VARCHAR(1) NOT NULL DEFAULT '',
|
||||
"category" TEXT NOT NULL DEFAULT '',
|
||||
"performanceScale" TEXT NOT NULL DEFAULT '',
|
||||
"salesScale" TEXT NOT NULL DEFAULT '',
|
||||
"finalScale" TEXT NOT NULL DEFAULT '',
|
||||
"profitLabel" TEXT NOT NULL DEFAULT '',
|
||||
"averageDailySales" DECIMAL(24,8),
|
||||
"feeRatio" DECIMAL(20,8),
|
||||
"grossMargin" DECIMAL(20,8),
|
||||
"shipAmount" DECIMAL(24,4) NOT NULL,
|
||||
"realPerformance" DECIMAL(24,4) NOT NULL,
|
||||
"shipQty" BIGINT NOT NULL,
|
||||
"grossProfit" DECIMAL(24,4) NOT NULL,
|
||||
"afterSaleAmount" DECIMAL(24,4) NOT NULL,
|
||||
"status" VARCHAR(24) NOT NULL DEFAULT 'CALCULATED',
|
||||
"reason" TEXT NOT NULL DEFAULT '',
|
||||
"calculatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SupplyChainProductArchiveSabcResult_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "SupplyChainProductArchiveSabcRun" (
|
||||
"id" TEXT NOT NULL,
|
||||
"periodStart" DATE NOT NULL,
|
||||
"periodEnd" DATE NOT NULL,
|
||||
"mode" VARCHAR(16) NOT NULL,
|
||||
"status" VARCHAR(24) NOT NULL,
|
||||
"sourceRows" INTEGER NOT NULL DEFAULT 0,
|
||||
"eligibleRows" INTEGER NOT NULL DEFAULT 0,
|
||||
"taggedRows" INTEGER NOT NULL DEFAULT 0,
|
||||
"comparedRows" INTEGER NOT NULL DEFAULT 0,
|
||||
"mismatchRows" INTEGER NOT NULL DEFAULT 0,
|
||||
"errorMessage" TEXT,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "SupplyChainProductArchiveSabcRun_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "SupplyChainProductArchiveSabcResult_barcode_periodStart_periodEnd_key"
|
||||
ON "SupplyChainProductArchiveSabcResult"("barcode", "periodStart", "periodEnd");
|
||||
CREATE INDEX "SupplyChainProductArchiveSabcResult_periodStart_periodEnd_idx"
|
||||
ON "SupplyChainProductArchiveSabcResult"("periodStart", "periodEnd");
|
||||
CREATE INDEX "SupplyChainProductArchiveSabcResult_sabcRating_periodEnd_idx"
|
||||
ON "SupplyChainProductArchiveSabcResult"("sabcRating", "periodEnd");
|
||||
CREATE INDEX "SupplyChainProductArchiveSabcResult_archiveId_periodEnd_idx"
|
||||
ON "SupplyChainProductArchiveSabcResult"("archiveId", "periodEnd");
|
||||
CREATE UNIQUE INDEX "SupplyChainProductArchiveSabcRun_periodStart_periodEnd_mode_key"
|
||||
ON "SupplyChainProductArchiveSabcRun"("periodStart", "periodEnd", "mode");
|
||||
CREATE INDEX "SupplyChainProductArchiveSabcRun_status_startedAt_idx"
|
||||
ON "SupplyChainProductArchiveSabcRun"("status", "startedAt");
|
||||
|
||||
ALTER TABLE "SupplyChainProductArchiveSabcResult"
|
||||
ADD CONSTRAINT "SupplyChainProductArchiveSabcResult_archiveId_fkey"
|
||||
FOREIGN KEY ("archiveId") REFERENCES "SupplyChainProductArchive"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -1,2 +0,0 @@
|
||||
-- Add factory stock (scmStock from plm_productDiscontinuation) to the archive main table.
|
||||
ALTER TABLE "SupplyChainProductArchive" ADD COLUMN "stockFactory" TEXT NOT NULL DEFAULT '';
|
||||
@ -1,29 +0,0 @@
|
||||
-- 全局下载中心:后台导出/下载任务
|
||||
CREATE TABLE "DownloadTask" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"userName" TEXT NOT NULL DEFAULT '',
|
||||
"type" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL DEFAULT '',
|
||||
"paramsJson" TEXT NOT NULL DEFAULT '{}',
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"progress" INTEGER NOT NULL DEFAULT 0,
|
||||
"fileName" TEXT NOT NULL DEFAULT '',
|
||||
"filePath" TEXT NOT NULL DEFAULT '',
|
||||
"fileSize" INTEGER NOT NULL DEFAULT 0,
|
||||
"rowCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"error" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"startedAt" TIMESTAMP(3),
|
||||
"finishedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "DownloadTask_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "DownloadTask_userId_createdAt_idx" ON "DownloadTask"("userId", "createdAt" DESC);
|
||||
CREATE INDEX "DownloadTask_status_idx" ON "DownloadTask"("status");
|
||||
|
||||
ALTER TABLE "DownloadTask"
|
||||
ADD CONSTRAINT "DownloadTask_status_check"
|
||||
CHECK ("status" IN ('pending', 'processing', 'completed', 'failed'));
|
||||
@ -1,41 +0,0 @@
|
||||
ALTER TABLE "WorkRecordScoreRule"
|
||||
ADD COLUMN "workFrequency" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "initiativeType" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX "WorkRecordScoreRule_workFrequency_sortOrder_idx"
|
||||
ON "WorkRecordScoreRule"("workFrequency", "sortOrder");
|
||||
|
||||
CREATE INDEX "WorkRecordScoreRule_initiativeType_sortOrder_idx"
|
||||
ON "WorkRecordScoreRule"("initiativeType", "sortOrder");
|
||||
|
||||
CREATE TABLE "WorkRecordBusinessOption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "WorkRecordBusinessOption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "WorkRecordBusinessOption_type_value_key"
|
||||
ON "WorkRecordBusinessOption"("type", "value");
|
||||
|
||||
CREATE INDEX "WorkRecordBusinessOption_type_sortOrder_idx"
|
||||
ON "WorkRecordBusinessOption"("type", "sortOrder");
|
||||
|
||||
CREATE INDEX "WorkRecordBusinessOption_type_isActive_sortOrder_idx"
|
||||
ON "WorkRecordBusinessOption"("type", "isActive", "sortOrder");
|
||||
|
||||
INSERT INTO "WorkRecordBusinessOption" ("id", "type", "value", "sortOrder", "isActive", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
('employee-hours-category-product', 'BUSINESS_CATEGORY', '产品开发', 1, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('employee-hours-category-design', 'BUSINESS_CATEGORY', '设计支持', 2, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('employee-hours-category-operation', 'BUSINESS_CATEGORY', '日常运营', 3, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('employee-hours-frequency-daily', 'WORK_FREQUENCY', '每日', 1, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('employee-hours-frequency-weekly', 'WORK_FREQUENCY', '每周', 2, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('employee-hours-frequency-monthly', 'WORK_FREQUENCY', '每月', 3, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('employee-hours-frequency-temporary', 'WORK_FREQUENCY', '临时', 4, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('employee-hours-initiative-active', 'INITIATIVE_TYPE', '主动', 1, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('employee-hours-initiative-passive', 'INITIATIVE_TYPE', '被动', 2, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
@ -1,2 +0,0 @@
|
||||
ALTER TABLE "SupplyChainProductArchive"
|
||||
ADD COLUMN "productAttribute" TEXT NOT NULL DEFAULT '';
|
||||
@ -1,35 +0,0 @@
|
||||
CREATE TABLE "EmployeeHoursRecord" (
|
||||
"id" TEXT NOT NULL,
|
||||
"workDate" DATE NOT NULL,
|
||||
"nickname" TEXT NOT NULL,
|
||||
"businessContent" TEXT NOT NULL,
|
||||
"businessNote" TEXT NOT NULL DEFAULT '',
|
||||
"businessCategory" TEXT NOT NULL,
|
||||
"workFrequency" TEXT NOT NULL,
|
||||
"businessSource" TEXT NOT NULL,
|
||||
"startTime" VARCHAR(16) NOT NULL,
|
||||
"endTime" VARCHAR(16) NOT NULL,
|
||||
"minutes" INTEGER NOT NULL,
|
||||
"hours" DECIMAL(10,2) NOT NULL,
|
||||
"estimatedScore" DECIMAL(12,4) NOT NULL,
|
||||
"approvedScore" DECIMAL(12,4),
|
||||
"department" TEXT NOT NULL DEFAULT '',
|
||||
"createdById" TEXT NOT NULL,
|
||||
"updatedById" TEXT NOT NULL,
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "EmployeeHoursRecord_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "EmployeeHoursRecord_workDate_deletedAt_idx"
|
||||
ON "EmployeeHoursRecord"("workDate", "deletedAt");
|
||||
|
||||
CREATE INDEX "EmployeeHoursRecord_nickname_workDate_idx"
|
||||
ON "EmployeeHoursRecord"("nickname", "workDate");
|
||||
|
||||
CREATE INDEX "EmployeeHoursRecord_businessCategory_workDate_idx"
|
||||
ON "EmployeeHoursRecord"("businessCategory", "workDate");
|
||||
|
||||
CREATE INDEX "EmployeeHoursRecord_department_workDate_idx"
|
||||
ON "EmployeeHoursRecord"("department", "workDate");
|
||||
@ -1,3 +0,0 @@
|
||||
-- 店铺状态(数字码:0=正常、6=已关闭、3=已出售;来源公司报表库 rpt.fys_shopInfo,每日定时同步)
|
||||
-- NULL 表示尚未同步或在公司报表库中未匹配到对应店铺
|
||||
ALTER TABLE "StoreAccount" ADD COLUMN "shopState" INTEGER;
|
||||
@ -1,26 +0,0 @@
|
||||
-- 类目配置增加「分类」维度,并新增分类规模档位表
|
||||
ALTER TABLE "SupplyChainProductArchiveCategoryConfig" ADD COLUMN "categoryGroup" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX "SupplyChainProductArchiveCategoryConfig_categoryGroup_idx" ON "SupplyChainProductArchiveCategoryConfig"("categoryGroup");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SupplyChainProductArchiveCategoryGroupTier" (
|
||||
"id" TEXT NOT NULL,
|
||||
"categoryGroup" TEXT NOT NULL,
|
||||
"scaleLabel" TEXT NOT NULL,
|
||||
"performanceStart" DECIMAL(14,2) NOT NULL,
|
||||
"performanceEnd" DECIMAL(14,2),
|
||||
"dailySalesStart" DECIMAL(12,2) NOT NULL,
|
||||
"dailySalesEnd" DECIMAL(12,2),
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SupplyChainProductArchiveCategoryGroupTier_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SupplyChainProductArchiveCategoryGroupTier_categoryGroup_scaleLabel_key" ON "SupplyChainProductArchiveCategoryGroupTier"("categoryGroup", "scaleLabel");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SupplyChainProductArchiveCategoryGroupTier_categoryGroup_idx" ON "SupplyChainProductArchiveCategoryGroupTier"("categoryGroup");
|
||||
@ -1,6 +0,0 @@
|
||||
-- 类目配置增加规模标签与业绩/日销阈值字段(终点可空 = 不设上限)
|
||||
ALTER TABLE "SupplyChainProductArchiveCategoryConfig" ADD COLUMN "scaleLabel" TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE "SupplyChainProductArchiveCategoryConfig" ADD COLUMN "performanceStart" DECIMAL(14,2);
|
||||
ALTER TABLE "SupplyChainProductArchiveCategoryConfig" ADD COLUMN "performanceEnd" DECIMAL(14,2);
|
||||
ALTER TABLE "SupplyChainProductArchiveCategoryConfig" ADD COLUMN "dailySalesStart" DECIMAL(12,2);
|
||||
ALTER TABLE "SupplyChainProductArchiveCategoryConfig" ADD COLUMN "dailySalesEnd" DECIMAL(12,2);
|
||||
@ -1,19 +0,0 @@
|
||||
-- 类目规模行表:一行 = 一个二级类目 × 一档规模标签
|
||||
CREATE TABLE "SupplyChainProductArchiveCategoryScaleRow" (
|
||||
"id" TEXT NOT NULL,
|
||||
"categoryL2" TEXT NOT NULL,
|
||||
"scaleLabel" TEXT NOT NULL,
|
||||
"performanceStart" DECIMAL(14,2),
|
||||
"performanceEnd" DECIMAL(14,2),
|
||||
"dailySalesStart" DECIMAL(12,2),
|
||||
"dailySalesEnd" DECIMAL(12,2),
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SupplyChainProductArchiveCategoryScaleRow_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "SupplyChainProductArchiveCategoryScaleRow_categoryL2_scaleLabel_key" ON "SupplyChainProductArchiveCategoryScaleRow"("categoryL2", "scaleLabel");
|
||||
|
||||
CREATE INDEX "SupplyChainProductArchiveCategoryScaleRow_categoryL2_idx" ON "SupplyChainProductArchiveCategoryScaleRow"("categoryL2");
|
||||
@ -1,26 +0,0 @@
|
||||
CREATE TABLE "EmployeeHoursAccessGrant" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL DEFAULT 'SELF',
|
||||
"updatedById" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "EmployeeHoursAccessGrant_pkey" PRIMARY KEY ("userId"),
|
||||
CONSTRAINT "EmployeeHoursAccessGrant_scope_check" CHECK ("scope" IN ('SELF', 'DEPARTMENT', 'ALL'))
|
||||
);
|
||||
|
||||
CREATE TABLE "EmployeeHoursAuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"area" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"actorId" TEXT NOT NULL,
|
||||
"actorName" TEXT NOT NULL,
|
||||
"targetId" TEXT,
|
||||
"message" TEXT NOT NULL,
|
||||
"details" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "EmployeeHoursAuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "EmployeeHoursAuditLog_area_createdAt_idx" ON "EmployeeHoursAuditLog"("area", "createdAt");
|
||||
CREATE INDEX "EmployeeHoursAuditLog_actorId_createdAt_idx" ON "EmployeeHoursAuditLog"("actorId", "createdAt");
|
||||
CREATE INDEX "EmployeeHoursAuditLog_status_createdAt_idx" ON "EmployeeHoursAuditLog"("status", "createdAt");
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user