Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/tmp-supply-chain-mainline-20260811

This commit is contained in:
WuYongJie 2026-08-11 10:40:13 +08:00
commit be247f5a4b
12 changed files with 674 additions and 254 deletions

View File

@ -15,7 +15,9 @@ JST_REPORT_DB_PASSWORD=
JST_REPORT_DB_ENCRYPT=true
JST_REPORT_DB_TRUST_SERVER_CERTIFICATE=true
# OA 新品流程看板快照来源(MySQL)
# OA 新品流程看板数据源。默认 mysql;切换到 JSReportReplica 时设为 mssql。
OA_BOARD_SOURCE_DRIVER=mysql
# MySQL 来源兼容配置。
OA_BOARD_SOURCE_DB_HOST=
OA_BOARD_SOURCE_DB_PORT=3306
OA_BOARD_SOURCE_DB_NAME=
@ -24,6 +26,30 @@ OA_BOARD_SOURCE_DB_PASSWORD=
OA_BOARD_SOURCE_DB_CHARSET=utf8mb4
OA_BOARD_SOURCE_DB_SSL=false
# SQL Server 来源:JSReportReplica.rpt 的 ding_Workflow、ding_WorkflowScheme、ding_WorkflowTask 只读视图。
# 使用 mssql 来源时,快照仍写入下方配置的企划 MySQL 库。
OA_BOARD_MSSQL_HOST=
OA_BOARD_MSSQL_PORT=1433
OA_BOARD_MSSQL_DATABASE=JSReportReplica
OA_BOARD_MSSQL_SCHEMA=rpt
OA_BOARD_MSSQL_USERNAME=
OA_BOARD_MSSQL_PASSWORD=
OA_BOARD_MSSQL_ENCRYPT=false
OA_BOARD_MSSQL_TRUST_SERVER_CERTIFICATE=true
OA_BOARD_MSSQL_CONNECTION_TIMEOUT_MS=15000
OA_BOARD_MSSQL_REQUEST_TIMEOUT_MS=120000
# OA 看板快照目标(MySQL)。留空时兼容复用 PLANNING_DATABASE_URL / PLANNING_DB_*。
# 配置此组变量后,可保证切换 SQL Server 来源时所有快照、超时跟进和日报数据仍保存在企划库。
OA_BOARD_SNAPSHOT_DATABASE_URL=
OA_BOARD_SNAPSHOT_DB_HOST=
OA_BOARD_SNAPSHOT_DB_PORT=3306
OA_BOARD_SNAPSHOT_DB_NAME=
OA_BOARD_SNAPSHOT_DB_USER=
OA_BOARD_SNAPSHOT_DB_PASSWORD=
OA_BOARD_SNAPSHOT_DB_CHARSET=utf8mb4
OA_BOARD_SNAPSHOT_DB_SSL=false
# HR 员工目录(SQL Server)。OA 快照仅在本地员工目录无法匹配时使用,
# 用于将 ding_WorkflowTask.userId 解析成当前负责人的姓名。
HR_SQLSERVER_HOST=

View File

@ -62,9 +62,9 @@ Usage:
Default mode is --dry-run.
当前 OA 新品流程看板默认优先读取 planning_database.oa_board_flow_detail_snapshot 快照表;
--apply 会从企划库直读并刷新快照表。
--apply 会从配置的数据源读取并刷新企划库快照表。
Preferred source environment variable:
MySQL source environment variable (OA_BOARD_SOURCE_DRIVER=mysql):
OA_BOARD_SOURCE_DATABASE_URL=mysql://user:password@host:port/database
Alternative source environment variables:
@ -81,6 +81,22 @@ Compatible fallback source environment variables:
PLANNING_DB_NAME
PLANNING_DB_USER
PLANNING_DB_PASSWORD
SQL Server source environment variables (OA_BOARD_SOURCE_DRIVER=mssql):
OA_BOARD_MSSQL_HOST
OA_BOARD_MSSQL_PORT
OA_BOARD_MSSQL_DATABASE
OA_BOARD_MSSQL_SCHEMA # default rpt
OA_BOARD_MSSQL_USERNAME
OA_BOARD_MSSQL_PASSWORD
Snapshot target environment variables (always MySQL):
OA_BOARD_SNAPSHOT_DATABASE_URL
OA_BOARD_SNAPSHOT_DB_HOST
OA_BOARD_SNAPSHOT_DB_PORT
OA_BOARD_SNAPSHOT_DB_NAME
OA_BOARD_SNAPSHOT_DB_USER
OA_BOARD_SNAPSHOT_DB_PASSWORD
`.trim())
}

View File

@ -586,6 +586,13 @@ export type NonIntegratedPricingRow = {
jstVirtualInventoryLastAttemptAt?: string;
jstVirtualInventoryError?: string;
jstVirtualInventorySoId?: string;
/** Read-only status from the separate fixed virtual-inventory task queue. */
fixedVirtualInventoryStatus?: "pending" | "pushed";
fixedVirtualInventoryScheduledAt?: string;
fixedVirtualInventoryPushedAt?: string;
fixedVirtualInventoryRetryCount?: string;
fixedVirtualInventoryLastAttemptAt?: string;
fixedVirtualInventoryError?: string;
};
export type NonIntegratedProductLimitRow = {

View File

@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react";
import { ProductPricingWorkspace } from "./ProductPricingWorkspace";
import type { ProductPricingDisplayRow } from "./product-workflow/productWorkflowRowHelpers";
function createPricingRow(): ProductPricingDisplayRow {
return {
skuId: "sku-1",
model: "MODEL-001",
skuCode: "SKU-001",
barcode: "BAR-001",
setCount: "1",
specName: "规格一",
supplierName: "供应商",
costPrice: "88",
productTag: "",
systemEnteredAt: "2026-08-10T08:00:00.000Z",
jstVirtualInventoryStatus: "failed",
jstVirtualInventoryScheduledAt: "",
jstVirtualInventoryPushedAt: "",
jstVirtualInventoryRetryCount: "0",
jstVirtualInventoryLastAttemptAt: "",
jstVirtualInventoryError: "聚水潭未找到对应仓库",
jstVirtualInventorySoId: "",
fixedVirtualInventoryStatus: "pushed",
fixedVirtualInventoryScheduledAt: "2026-08-10T09:00:00.000Z",
fixedVirtualInventoryPushedAt: "2026-08-10T09:00:01.000Z",
fixedVirtualInventoryRetryCount: "0",
fixedVirtualInventoryLastAttemptAt: "2026-08-10T09:00:01.000Z",
fixedVirtualInventoryError: "",
};
}
describe("ProductPricingWorkspace", () => {
it("shows the fixed virtual inventory result before a failed legacy result", () => {
const row = createPricingRow();
render(
<ProductPricingWorkspace
allRows={[row]}
displayedRows={[row]}
inheritedSkuBatchTabs={null}
isReadOnly={false}
isNodeLocked={false}
canUsePushControls={false}
isRowReadyForPush={() => true}
onUpdateRow={jest.fn()}
onConfirmPushRows={jest.fn()}
/>,
);
expect(screen.getByText("库存已推")).toHaveAttribute(
"title",
"固定虚拟库存已于 2026-08-10T09:00:01.000Z 推送",
);
expect(screen.queryByText("库存失败")).not.toBeInTheDocument();
});
});

View File

@ -38,6 +38,29 @@ function ReadOnlyCell({ value }: { value: string }) {
}
function getVirtualInventoryStatusMeta(row: ProductPricingDisplayRow) {
if (row.fixedVirtualInventoryStatus === "pending") {
const retryCount = Number(row.fixedVirtualInventoryRetryCount || "0");
return {
label: retryCount > 0 ? "库存重试中" : "库存待推",
tone: "bg-[#e7f1ff] text-[#1764ad]",
title: row.fixedVirtualInventoryError
? `固定虚拟库存推送失败,将于 ${row.fixedVirtualInventoryScheduledAt || "稍后"} 重试:${row.fixedVirtualInventoryError}`
: row.fixedVirtualInventoryScheduledAt
? `固定虚拟库存已入队,预计 ${row.fixedVirtualInventoryScheduledAt} 发起`
: "固定虚拟库存已入推送队列",
};
}
if (row.fixedVirtualInventoryStatus === "pushed") {
return {
label: "库存已推",
tone: "bg-[#dff7e8] text-[#137a38]",
title: row.fixedVirtualInventoryPushedAt
? `固定虚拟库存已于 ${row.fixedVirtualInventoryPushedAt} 推送`
: "固定虚拟库存已推送",
};
}
const virtualInventorySkipTitle =
row.jstVirtualInventoryError || "无需推虚拟库存";
const normalizedVirtualInventoryError =

View File

@ -6,6 +6,7 @@ import type {
import {
applyProductLimitWorkbookRows,
buildProductLimitRows,
normalizePricingRowsForPayload,
} from "./productWorkflowRowHelpers"
function createSkuItem(
@ -111,4 +112,26 @@ describe("productWorkflowRowHelpers", () => {
},
])
})
it("does not include fixed virtual inventory task fields in pricing saves", () => {
expect(
normalizePricingRowsForPayload([
{
skuId: "sku-test-1",
costPrice: "88",
productTag: "",
fixedVirtualInventoryStatus: "pushed",
fixedVirtualInventoryPushedAt: "2026-08-10T09:00:01.000Z",
fixedVirtualInventoryRetryCount: "2",
fixedVirtualInventoryError: "previous failure",
},
]),
).toEqual([
{
skuId: "sku-test-1",
costPrice: "88",
productTag: "",
},
])
})
})

View File

@ -130,6 +130,17 @@ export function createProductPricingRowFromSku(
jstVirtualInventoryError:
existingRow?.jstVirtualInventoryError?.trim() ?? "",
jstVirtualInventorySoId: existingRow?.jstVirtualInventorySoId?.trim() ?? "",
fixedVirtualInventoryStatus: existingRow?.fixedVirtualInventoryStatus,
fixedVirtualInventoryScheduledAt:
existingRow?.fixedVirtualInventoryScheduledAt?.trim() ?? "",
fixedVirtualInventoryPushedAt:
existingRow?.fixedVirtualInventoryPushedAt?.trim() ?? "",
fixedVirtualInventoryRetryCount:
existingRow?.fixedVirtualInventoryRetryCount?.trim() ?? "",
fixedVirtualInventoryLastAttemptAt:
existingRow?.fixedVirtualInventoryLastAttemptAt?.trim() ?? "",
fixedVirtualInventoryError:
existingRow?.fixedVirtualInventoryError?.trim() ?? "",
};
}

View File

@ -54,6 +54,22 @@ class JushuitanFixedVirtualInventoryTaskRepository {
});
}
async findByProductId(productId: string) {
return prisma.intakeJushuitanFixedVirtualInventoryTask.findMany({
where: { productId },
orderBy: [{ skuItemId: "asc" }, { createdAt: "asc" }],
select: {
skuItemId: true,
status: true,
scheduledAt: true,
retryCount: true,
lastAttemptAt: true,
pushedAt: true,
error: true,
},
});
}
async markSucceeded(input: FixedVirtualInventorySuccessInput) {
return prisma.intakeJushuitanFixedVirtualInventoryTask.update({
where: { id: input.id },
@ -86,7 +102,11 @@ export type JushuitanFixedVirtualInventoryTaskRecord =
IntakeJushuitanFixedVirtualInventoryTask;
export type JushuitanFixedVirtualInventoryTaskRepositoryContract = Pick<
JushuitanFixedVirtualInventoryTaskRepository,
"createMissing" | "findDue" | "markSucceeded" | "scheduleRetry"
| "createMissing"
| "findDue"
| "findByProductId"
| "markSucceeded"
| "scheduleRetry"
>;
export const jushuitanFixedVirtualInventoryTaskRepository =

View File

@ -94,6 +94,12 @@ jest.mock('../../repositories/draft-group.repository', () => ({
},
}))
jest.mock('../../repositories/jushuitan-fixed-virtual-inventory-task.repository', () => ({
jushuitanFixedVirtualInventoryTaskRepository: {
findByProductId: jest.fn(),
},
}))
jest.mock('../spu-number-reservation.service', () => ({
spuNumberReservationService: {
reserveInTransaction: jest.fn(),
@ -136,6 +142,7 @@ import { intakeNonIntegratedProductRepository } from '../../repositories/intake-
import { userRepository } from '../../repositories/user.repository'
import { assetSkuGroupRepository, assetVariantRepository } from '../../repositories/asset-center.repository'
import { draftGroupRepository, preSkuRepository } from '../../repositories/draft-group.repository'
import { jushuitanFixedVirtualInventoryTaskRepository } from '../../repositories/jushuitan-fixed-virtual-inventory-task.repository'
import { prisma } from '@/lib/prisma'
import { ErrorCode } from '@/server/core/errors'
import {
@ -157,6 +164,9 @@ const mockedAssetSkuGroupRepository = jest.mocked(assetSkuGroupRepository)
const mockedAssetVariantRepository = jest.mocked(assetVariantRepository)
const mockedDraftGroupRepository = jest.mocked(draftGroupRepository)
const mockedPreSkuRepository = jest.mocked(preSkuRepository)
const mockedFixedVirtualInventoryTaskRepository = jest.mocked(
jushuitanFixedVirtualInventoryTaskRepository,
)
const mockedSpuNumberReservationService = jest.mocked(spuNumberReservationService)
const mockedPrisma = jest.mocked(prisma)
const mockedNodePermissionSettingsService = jest.mocked(intakeNodePermissionSettingsService)
@ -447,6 +457,7 @@ describe('intakeNonIntegratedProductService', () => {
mockedRepository.findActiveRecordsBySpuInTransaction.mockResolvedValue([] as never)
mockedNodePermissionSettingsService.getSettings.mockResolvedValue({ overrides: {} })
mockedUserRepository.findIdsByBusinessRole.mockResolvedValue([])
mockedFixedVirtualInventoryTaskRepository.findByProductId.mockResolvedValue([])
})
it('lists all shared records when permissions are relaxed', async () => {
@ -1237,6 +1248,53 @@ describe('intakeNonIntegratedProductService', () => {
])
})
it('returns fixed virtual inventory task status alongside the legacy status', async () => {
mockedRepository.findActiveById.mockResolvedValue(
createRecord({
payload: createPayload({
skuItems: [createSkuItem({ id: 'sku-1' })],
pricingRows: [
{
skuId: 'sku-1',
costPrice: '199.00',
productTag: '',
jstVirtualInventoryStatus: 'failed',
jstVirtualInventoryError: '聚水潭未找到对应仓库',
},
],
}),
}) as never,
)
mockedFixedVirtualInventoryTaskRepository.findByProductId.mockResolvedValue([
{
skuItemId: 'sku-1',
status: 'pushed',
scheduledAt: new Date('2026-08-10T09:00:00.000Z'),
retryCount: 0,
lastAttemptAt: new Date('2026-08-10T09:00:00.000Z'),
pushedAt: new Date('2026-08-10T09:00:01.000Z'),
error: '',
},
] as never)
const product = await intakeNonIntegratedProductService.getById(
'non-integrated-1',
baseUser,
)
expect(mockedFixedVirtualInventoryTaskRepository.findByProductId).toHaveBeenCalledWith(
'non-integrated-1',
)
expect(product?.pricingRows).toEqual([
expect.objectContaining({
skuId: 'sku-1',
jstVirtualInventoryStatus: 'failed',
fixedVirtualInventoryStatus: 'pushed',
fixedVirtualInventoryPushedAt: '2026-08-10T09:00:01.000Z',
}),
])
})
it('preserves the legacy modular tag after all pricing rows are already entered', async () => {
mockedRepository.findActiveById.mockResolvedValue(
createRecord({

View File

@ -70,6 +70,7 @@ import {
draftGroupRepository,
preSkuRepository,
} from '../repositories/draft-group.repository'
import { jushuitanFixedVirtualInventoryTaskRepository } from '../repositories/jushuitan-fixed-virtual-inventory-task.repository'
import {
assertNonIntegratedProductMasterDataReadyForFormalUse,
resolveNonIntegratedSkuQuality,
@ -1979,6 +1980,51 @@ function buildStructuredProductRecordFromRecord(record: IntakeNonIntegratedRecor
return applyPersistedRecordIdentity(applySharedProductTag(hydratedProduct), record)
}
async function hydrateFixedVirtualInventoryStatuses(
product: SharedNonIntegratedProductRecord,
): Promise<SharedNonIntegratedProductRecord> {
const tasks = await jushuitanFixedVirtualInventoryTaskRepository.findByProductId(
product.id,
)
if (tasks.length === 0 || product.pricingRows.length === 0) return product
const taskBySkuItemId = new Map(tasks.map((task) => [task.skuItemId, task]))
let changed = false
const pricingRows = product.pricingRows.map((row) => {
const task = taskBySkuItemId.get(row.skuId)
const fixedVirtualInventoryStatus: NonIntegratedPricingRow['fixedVirtualInventoryStatus'] =
task?.status === 'pending'
? 'pending'
: task?.status === 'pushed'
? 'pushed'
: undefined
if (!task || !fixedVirtualInventoryStatus) {
return row
}
changed = true
return {
...row,
fixedVirtualInventoryStatus,
...(task.scheduledAt
? { fixedVirtualInventoryScheduledAt: task.scheduledAt.toISOString() }
: {}),
...(task.pushedAt
? { fixedVirtualInventoryPushedAt: task.pushedAt.toISOString() }
: {}),
...(task.retryCount > 0
? { fixedVirtualInventoryRetryCount: String(task.retryCount) }
: {}),
...(task.lastAttemptAt
? { fixedVirtualInventoryLastAttemptAt: task.lastAttemptAt.toISOString() }
: {}),
...(task.error ? { fixedVirtualInventoryError: task.error } : {}),
}
})
return changed ? { ...product, pricingRows } : product
}
function buildBarcodePrintProductFromPricingRecord(record: BarcodePrintPricingRecord) {
return buildStructuredProductRecordFromRecord({
...record.product,
@ -6265,7 +6311,9 @@ class IntakeNonIntegratedProductService {
if (!scopedProduct) return null
const hydratedProduct = await hydrateSeriesProductDisplayName(scopedProduct)
return hydrateProductSkuModelSnapshots(hydratedProduct)
const productWithInventoryStatus =
await hydrateFixedVirtualInventoryStatuses(hydratedProduct)
return hydrateProductSkuModelSnapshots(productWithInventoryStatus)
}
async getDownloadableById(

View File

@ -43,6 +43,7 @@ function createRepository(tasks: JushuitanFixedVirtualInventoryTaskRecord[]) {
return {
createMissing: jest.fn(async () => 1),
findDue: jest.fn(async () => tasks),
findByProductId: jest.fn(async () => []),
markSucceeded: jest.fn(async () => undefined),
scheduleRetry: jest.fn(async () => undefined),
} satisfies JushuitanFixedVirtualInventoryTaskRepositoryContract;

View File

@ -3,6 +3,7 @@ import {
type ConnectionOptions,
type RowDataPacket,
} from 'mysql2/promise'
import type sql from 'mssql'
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { externalEmployeeDirectoryService } from './external-employee-directory.service'
@ -322,60 +323,41 @@ function readFirstEnv(names: string[]) {
return names.map(readEnv).find(Boolean) ?? ''
}
export function createPlanningConnectionConfig(): string | ConnectionOptions {
const databaseUrl = readFirstEnv([
'OA_BOARD_SOURCE_DATABASE_URL',
'PLANNING_DATABASE_URL',
])
type OaBoardSourceDriver = 'mysql' | 'mssql'
if (databaseUrl) {
return databaseUrl
}
function readOaBoardSourceDriver(): OaBoardSourceDriver {
const configured = readEnv('OA_BOARD_SOURCE_DRIVER').toLowerCase()
if (!configured || configured === 'mysql') return 'mysql'
if (configured === 'mssql' || configured === 'sqlserver') return 'mssql'
throw new Error(`Unsupported OA_BOARD_SOURCE_DRIVER: ${configured}`)
}
const host = readFirstEnv([
'OA_BOARD_SOURCE_DB_HOST',
'PLANNING_DB_HOST',
'JST_PLANNING_DB_HOST',
])
const database = readFirstEnv([
'OA_BOARD_SOURCE_DB_NAME',
'PLANNING_DB_NAME',
'JST_PLANNING_DB_NAME',
])
const user = readFirstEnv([
'OA_BOARD_SOURCE_DB_USER',
'PLANNING_DB_USER',
'JST_PLANNING_DB_USER',
])
const password = readFirstEnv([
'OA_BOARD_SOURCE_DB_PASSWORD',
'PLANNING_DB_PASSWORD',
'JST_PLANNING_DB_PASSWORD',
])
function createMysqlConnectionConfig(options: {
databaseUrlNames: string[]
hostNames: string[]
portNames: string[]
databaseNames: string[]
userNames: string[]
passwordNames: string[]
charsetNames: string[]
sslNames: string[]
missingMessage: string
}): string | ConnectionOptions {
const databaseUrl = readFirstEnv(options.databaseUrlNames)
if (databaseUrl) return databaseUrl
const host = readFirstEnv(options.hostNames)
const database = readFirstEnv(options.databaseNames)
const user = readFirstEnv(options.userNames)
const password = readFirstEnv(options.passwordNames)
if (!host || !database || !user || !password) {
throw new Error('OA 看板来源数据库未配置')
throw new Error(options.missingMessage)
}
const port = Number(
readFirstEnv([
'OA_BOARD_SOURCE_DB_PORT',
'PLANNING_DB_PORT',
'JST_PLANNING_DB_PORT',
]) || '3306',
)
const charset =
readFirstEnv([
'OA_BOARD_SOURCE_DB_CHARSET',
'PLANNING_DB_CHARSET',
'JST_PLANNING_DB_CHARSET',
]) || 'utf8mb4'
const sslEnabled =
readFirstEnv([
'OA_BOARD_SOURCE_DB_SSL',
'PLANNING_DB_SSL',
'JST_PLANNING_DB_SSL',
]) === 'true'
const port = Number(readFirstEnv(options.portNames) || '3306')
const charset = readFirstEnv(options.charsetNames) || 'utf8mb4'
const sslEnabled = readFirstEnv(options.sslNames) === 'true'
return {
host,
@ -390,6 +372,83 @@ export function createPlanningConnectionConfig(): string | ConnectionOptions {
}
}
export function createOaBoardSnapshotConnectionConfig(): string | ConnectionOptions {
return createMysqlConnectionConfig({
databaseUrlNames: ['OA_BOARD_SNAPSHOT_DATABASE_URL', 'PLANNING_DATABASE_URL'],
hostNames: ['OA_BOARD_SNAPSHOT_DB_HOST', 'PLANNING_DB_HOST', 'JST_PLANNING_DB_HOST'],
portNames: ['OA_BOARD_SNAPSHOT_DB_PORT', 'PLANNING_DB_PORT', 'JST_PLANNING_DB_PORT'],
databaseNames: ['OA_BOARD_SNAPSHOT_DB_NAME', 'PLANNING_DB_NAME', 'JST_PLANNING_DB_NAME'],
userNames: ['OA_BOARD_SNAPSHOT_DB_USER', 'PLANNING_DB_USER', 'JST_PLANNING_DB_USER'],
passwordNames: ['OA_BOARD_SNAPSHOT_DB_PASSWORD', 'PLANNING_DB_PASSWORD', 'JST_PLANNING_DB_PASSWORD'],
charsetNames: ['OA_BOARD_SNAPSHOT_DB_CHARSET', 'PLANNING_DB_CHARSET', 'JST_PLANNING_DB_CHARSET'],
sslNames: ['OA_BOARD_SNAPSHOT_DB_SSL', 'PLANNING_DB_SSL', 'JST_PLANNING_DB_SSL'],
missingMessage: 'OA 看板快照数据库未配置',
})
}
export function createPlanningConnectionConfig(): string | ConnectionOptions {
return createOaBoardSnapshotConnectionConfig()
}
function createMysqlSourceConnectionConfig(): string | ConnectionOptions {
return createMysqlConnectionConfig({
databaseUrlNames: ['OA_BOARD_SOURCE_DATABASE_URL', 'PLANNING_DATABASE_URL'],
hostNames: ['OA_BOARD_SOURCE_DB_HOST', 'PLANNING_DB_HOST', 'JST_PLANNING_DB_HOST'],
portNames: ['OA_BOARD_SOURCE_DB_PORT', 'PLANNING_DB_PORT', 'JST_PLANNING_DB_PORT'],
databaseNames: ['OA_BOARD_SOURCE_DB_NAME', 'PLANNING_DB_NAME', 'JST_PLANNING_DB_NAME'],
userNames: ['OA_BOARD_SOURCE_DB_USER', 'PLANNING_DB_USER', 'JST_PLANNING_DB_USER'],
passwordNames: ['OA_BOARD_SOURCE_DB_PASSWORD', 'PLANNING_DB_PASSWORD', 'JST_PLANNING_DB_PASSWORD'],
charsetNames: ['OA_BOARD_SOURCE_DB_CHARSET', 'PLANNING_DB_CHARSET', 'JST_PLANNING_DB_CHARSET'],
sslNames: ['OA_BOARD_SOURCE_DB_SSL', 'PLANNING_DB_SSL', 'JST_PLANNING_DB_SSL'],
missingMessage: 'OA 看板来源数据库未配置',
})
}
function assertSqlServerIdentifier(value: string, label: string) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
throw new Error(`Invalid ${label}: ${value}`)
}
}
function quoteSqlServerIdentifier(value: string, label: string) {
assertSqlServerIdentifier(value, label)
return `[${value}]`
}
function createSqlServerSourceConfig(): sql.config {
const host = readEnv('OA_BOARD_MSSQL_HOST')
const database = readEnv('OA_BOARD_MSSQL_DATABASE')
const user = readEnv('OA_BOARD_MSSQL_USERNAME')
const password = readEnv('OA_BOARD_MSSQL_PASSWORD')
if (!host || !database || !user || !password) {
throw new Error('OA 看板 SQL Server 来源数据库未配置')
}
return {
server: host,
port: Number(readEnv('OA_BOARD_MSSQL_PORT') || '1433'),
database,
user,
password,
options: {
encrypt: readEnv('OA_BOARD_MSSQL_ENCRYPT') === 'true',
trustServerCertificate: readEnv('OA_BOARD_MSSQL_TRUST_SERVER_CERTIFICATE') !== 'false',
},
connectionTimeout: Number(readEnv('OA_BOARD_MSSQL_CONNECTION_TIMEOUT_MS') || '15000'),
requestTimeout: Number(readEnv('OA_BOARD_MSSQL_REQUEST_TIMEOUT_MS') || '120000'),
}
}
async function loadSqlServer() {
const module = await import('mssql')
return module.default
}
function getSqlServerSourceSchema() {
return quoteSqlServerIdentifier(readEnv('OA_BOARD_MSSQL_SCHEMA') || 'rpt', 'OA_BOARD_MSSQL_SCHEMA')
}
function normalizeText(value: unknown): string | null {
const text = String(value ?? '').trim()
return text || null
@ -2474,171 +2533,129 @@ function buildProjectGraph(
}
}
async function fetchSourceData(limit?: number): Promise<NormalizedFlowRecord[]> {
const connectionConfig = createPlanningConnectionConfig()
type OaBoardSourceRows = {
workflowRows: SourceWorkflowRow[]
schemeRows: SourceSchemeRow[]
taskRows: SourceTaskRow[]
}
function sourceWorkflowColumns(prefix = '') {
const column = (name: string) => `${prefix}${name}`
return [
`${column('finishTime')} AS finishTime`,
`${column('businessId')} AS businessId`,
`${column('title')} AS title`,
`${column('result')} AS result`,
`${column('originatorUserId')} AS originatorUserId`,
`${column('originatorDeptId')} AS originatorDeptId`,
`${column('originatorDeptName')} AS originatorDeptName`,
`${column('status')} AS status`,
`${column('bizAction')} AS bizAction`,
`${column('processInstanceId')} AS processInstanceId`,
`${column('joinAt')} AS joinAt`,
`${column('updateAt')} AS updateAt`,
`${column('createAt')} AS createAt`,
`${column('processCode')} AS processCode`,
`${column('flowType')} AS flowType`,
].join(', ')
}
function buildSourceLinkFieldNames(limit?: number) {
return Array.from(
new Set([
...(limit ? OA_BOARD_JOIN_FIELD_NAMES.openingCode : []),
...OA_BOARD_JOIN_FIELD_NAMES.orderCode,
...OA_BOARD_JOIN_FIELD_NAMES.pricingCode,
...OA_BOARD_JOIN_FIELD_NAMES.artCode,
]),
)
}
function extractOpeningCodes(rows: SourceSchemeRow[]) {
return Array.from(
new Set(
rows
.filter((row) => matchesFieldName(row.name, OA_BOARD_JOIN_FIELD_NAMES.openingCode))
.map((row) => normalizeText(row.value))
.filter((value): value is string => Boolean(value)),
),
)
}
function uniqueSourceWorkflowRows(rows: SourceWorkflowRow[]) {
const workflowMap = new Map<string, SourceWorkflowRow>()
rows.forEach((row) => workflowMap.set(row.processInstanceId, row))
return Array.from(workflowMap.values())
}
function getSourceProcessInstanceIds(rows: SourceWorkflowRow[]) {
return Array.from(
new Set(
rows
.map((workflowRow) => normalizeText(workflowRow.processInstanceId))
.filter((value): value is string => Boolean(value)),
),
)
}
async function fetchMysqlSourceRows(limit?: number): Promise<OaBoardSourceRows> {
const connectionConfig = createMysqlSourceConnectionConfig()
const connection =
typeof connectionConfig === 'string'
? await mysql.createConnection(connectionConfig)
: await mysql.createConnection(connectionConfig)
try {
const processCodes = Object.values(OA_BOARD_PROCESS_CODES)
const reviewProcessCode = OA_BOARD_PROCESS_CODES.review
const processCodes = Object.values(OA_BOARD_PROCESS_CODES)
const workflowLimitSql = limit ? `LIMIT ${Math.max(1, Math.floor(limit))}` : ''
let workflowRows: SourceWorkflowRow[] = []
if (limit) {
const [reviewWorkflowRows] = await connection.query<SourceWorkflowRow[]>(
`
SELECT finishTime, businessId, title, result, originatorUserId, originatorDeptId,
originatorDeptName, status, bizAction, processInstanceId, joinAt,
updateAt, createAt, processCode, flowType
FROM ding_Workflow
WHERE processCode = ?
ORDER BY updateAt DESC
${workflowLimitSql}
`,
[reviewProcessCode],
)
const reviewProcessInstanceIds = reviewWorkflowRows.map((row) => row.processInstanceId)
const [reviewSchemeRows] =
reviewProcessInstanceIds.length > 0
? await connection.query<SourceSchemeRow[]>(
`
SELECT processInstanceId, componentType, name, CAST(value AS CHAR) AS value, CAST(extValue AS CHAR) AS extValue
FROM ding_WorkflowScheme
WHERE processInstanceId IN (${reviewProcessInstanceIds.map(() => '?').join(', ')})
`,
reviewProcessInstanceIds,
)
: [[] as SourceSchemeRow[]]
const openingCodes = Array.from(
new Set(
reviewSchemeRows
.filter((row) =>
matchesFieldName(row.name, OA_BOARD_JOIN_FIELD_NAMES.openingCode),
)
.map((row) => normalizeText(row.value))
.filter((value): value is string => Boolean(value)),
),
)
const linkedFieldNames = Array.from(
new Set([
...OA_BOARD_JOIN_FIELD_NAMES.openingCode,
...OA_BOARD_JOIN_FIELD_NAMES.orderCode,
...OA_BOARD_JOIN_FIELD_NAMES.pricingCode,
...OA_BOARD_JOIN_FIELD_NAMES.artCode,
]),
)
const otherProcessCodes = processCodes.filter((code) => code !== reviewProcessCode)
const [linkedWorkflowRows] =
openingCodes.length > 0
? await connection.query<SourceWorkflowRow[]>(
`
SELECT DISTINCT w.finishTime, w.businessId, w.title, w.result, w.originatorUserId, w.originatorDeptId,
w.originatorDeptName, w.status, w.bizAction, w.processInstanceId, w.joinAt,
w.updateAt, w.createAt, w.processCode, w.flowType
FROM ding_Workflow w
JOIN ding_WorkflowScheme s
ON s.processInstanceId = w.processInstanceId
WHERE w.processCode IN (${otherProcessCodes.map(() => '?').join(', ')})
AND s.name IN (${linkedFieldNames.map(() => '?').join(', ')})
AND CAST(s.value AS CHAR) IN (${openingCodes.map(() => '?').join(', ')})
ORDER BY w.updateAt DESC
`,
[...otherProcessCodes, ...linkedFieldNames, ...openingCodes],
)
: [[] as SourceWorkflowRow[]]
const workflowMap = new Map<string, SourceWorkflowRow>()
reviewWorkflowRows.forEach((row) => workflowMap.set(row.processInstanceId, row))
linkedWorkflowRows.forEach((row) => workflowMap.set(row.processInstanceId, row))
workflowRows = Array.from(workflowMap.values())
} else {
const [reviewWorkflowRows] = await connection.query<SourceWorkflowRow[]>(
`
SELECT finishTime, businessId, title, result, originatorUserId, originatorDeptId,
originatorDeptName, status, bizAction, processInstanceId, joinAt,
updateAt, createAt, processCode, flowType
FROM ding_Workflow
WHERE processCode = ?
ORDER BY updateAt DESC
`,
[reviewProcessCode],
)
const reviewProcessInstanceIds = reviewWorkflowRows.map((row) => row.processInstanceId)
const [reviewSchemeRows] =
reviewProcessInstanceIds.length > 0
? await connection.query<SourceSchemeRow[]>(
`
SELECT processInstanceId, componentType, name, CAST(value AS CHAR) AS value, CAST(extValue AS CHAR) AS extValue
FROM ding_WorkflowScheme
WHERE processInstanceId IN (${reviewProcessInstanceIds.map(() => '?').join(', ')})
`,
reviewProcessInstanceIds,
)
: [[] as SourceSchemeRow[]]
const openingCodes = Array.from(
new Set(
reviewSchemeRows
.filter((row) =>
matchesFieldName(row.name, OA_BOARD_JOIN_FIELD_NAMES.openingCode),
)
.map((row) => normalizeText(row.value))
.filter((value): value is string => Boolean(value)),
),
)
const linkedFieldNames = Array.from(
new Set([
...OA_BOARD_JOIN_FIELD_NAMES.orderCode,
...OA_BOARD_JOIN_FIELD_NAMES.pricingCode,
...OA_BOARD_JOIN_FIELD_NAMES.artCode,
]),
)
const otherProcessCodes = processCodes.filter((code) => code !== reviewProcessCode)
const [linkedWorkflowRows] =
openingCodes.length > 0
? await connection.query<SourceWorkflowRow[]>(
`
SELECT DISTINCT w.finishTime, w.businessId, w.title, w.result, w.originatorUserId, w.originatorDeptId,
w.originatorDeptName, w.status, w.bizAction, w.processInstanceId, w.joinAt,
w.updateAt, w.createAt, w.processCode, w.flowType
FROM ding_Workflow w
JOIN ding_WorkflowScheme s
ON s.processInstanceId = w.processInstanceId
WHERE w.processCode IN (${otherProcessCodes.map(() => '?').join(', ')})
AND s.name IN (${linkedFieldNames.map(() => '?').join(', ')})
AND CAST(s.value AS CHAR) IN (${openingCodes.map(() => '?').join(', ')})
ORDER BY w.updateAt DESC
`,
[...otherProcessCodes, ...linkedFieldNames, ...openingCodes],
)
: [[] as SourceWorkflowRow[]]
const workflowMap = new Map<string, SourceWorkflowRow>()
reviewWorkflowRows.forEach((row) => workflowMap.set(row.processInstanceId, row))
linkedWorkflowRows.forEach((row) => workflowMap.set(row.processInstanceId, row))
workflowRows = Array.from(workflowMap.values())
}
const processInstanceIds = Array.from(
new Set(
workflowRows
.map((workflowRow) => normalizeText(workflowRow.processInstanceId))
.filter((value): value is string => Boolean(value)),
),
const [reviewWorkflowRows] = await connection.query<SourceWorkflowRow[]>(
`
SELECT ${sourceWorkflowColumns()}
FROM ding_Workflow
WHERE processCode = ?
ORDER BY updateAt DESC
${workflowLimitSql}
`,
[reviewProcessCode],
)
const reviewProcessInstanceIds = getSourceProcessInstanceIds(reviewWorkflowRows)
const [reviewSchemeRows] =
reviewProcessInstanceIds.length > 0
? await connection.query<SourceSchemeRow[]>(
`
SELECT processInstanceId, componentType, name, CAST(value AS CHAR) AS value, CAST(extValue AS CHAR) AS extValue
FROM ding_WorkflowScheme
WHERE processInstanceId IN (${reviewProcessInstanceIds.map(() => '?').join(', ')})
`,
reviewProcessInstanceIds,
)
: [[] as SourceSchemeRow[]]
const openingCodes = extractOpeningCodes(reviewSchemeRows)
const otherProcessCodes = processCodes.filter((code) => code !== reviewProcessCode)
const linkedFieldNames = buildSourceLinkFieldNames(limit)
const [linkedWorkflowRows] =
openingCodes.length > 0
? await connection.query<SourceWorkflowRow[]>(
`
SELECT DISTINCT ${sourceWorkflowColumns('w.')}
FROM ding_Workflow w
JOIN ding_WorkflowScheme s ON s.processInstanceId = w.processInstanceId
WHERE w.processCode IN (${otherProcessCodes.map(() => '?').join(', ')})
AND s.name IN (${linkedFieldNames.map(() => '?').join(', ')})
AND CAST(s.value AS CHAR) IN (${openingCodes.map(() => '?').join(', ')})
ORDER BY w.updateAt DESC
`,
[...otherProcessCodes, ...linkedFieldNames, ...openingCodes],
)
: [[] as SourceWorkflowRow[]]
const workflowRows = uniqueSourceWorkflowRows([...reviewWorkflowRows, ...linkedWorkflowRows])
const processInstanceIds = getSourceProcessInstanceIds(workflowRows)
if (processInstanceIds.length === 0) {
return []
return { workflowRows: [], schemeRows: [], taskRows: [] }
}
const [schemeRows] = await connection.query<SourceSchemeRow[]>(
@ -2649,68 +2666,181 @@ async function fetchSourceData(limit?: number): Promise<NormalizedFlowRecord[]>
`,
processInstanceIds,
)
const [taskRows] = await connection.query<SourceTaskRow[]>(
`
SELECT t.id, t.processInstanceId, t.result, t.activityId, t.finishTime, t.createTime, t.userId, t.taskId, t.status,
w.processCode AS processCode
FROM ding_WorkflowTask t
JOIN ding_Workflow w
ON w.processInstanceId = t.processInstanceId
JOIN ding_Workflow w ON w.processInstanceId = t.processInstanceId
WHERE t.processInstanceId IN (${processInstanceIds.map(() => '?').join(', ')})
`,
processInstanceIds,
)
const schemeMap = new Map<string, SourceSchemeRow[]>()
schemeRows.forEach((schemeRow) => {
const current = schemeMap.get(schemeRow.processInstanceId) ?? []
current.push(schemeRow)
schemeMap.set(schemeRow.processInstanceId, current)
})
const taskMap = new Map<string, SourceTaskRow[]>()
taskRows.forEach((taskRow) => {
const current = taskMap.get(taskRow.processInstanceId) ?? []
current.push(taskRow)
taskMap.set(taskRow.processInstanceId, current)
})
const [mappingLookup, nodeRuleLookup] = await Promise.all([
loadActivityMappingLookup(),
loadNodeRuleLookup(),
])
return workflowRows
.map((workflowRow) => {
const flowRecord = buildNormalizedFlowRecord(
workflowRow,
buildFieldSnapshots(schemeMap.get(workflowRow.processInstanceId) ?? []),
buildTaskRecordsWithMappings(
taskMap.get(workflowRow.processInstanceId) ?? [],
OA_BOARD_FLOW_LABELS[inferFlowType(workflowRow.processCode) as OaBoardFlowType] ??
workflowRow.processCode,
mappingLookup,
),
)
if (!flowRecord) return null
return {
...flowRecord,
taskRecords: applyNodeRuleTargets(
flowRecord.taskRecords,
flowRecord.productType,
nodeRuleLookup,
),
}
})
.filter((record): record is NormalizedFlowRecord => Boolean(record))
return { workflowRows, schemeRows, taskRows }
} finally {
await connection.end()
}
}
function addSqlServerTextParameters(
sqlServer: typeof import('mssql').default,
request: sql.Request,
prefix: string,
values: string[],
) {
return values.map((value, index) => {
const name = `${prefix}${index}`
request.input(name, sqlServer.NVarChar(4000), value)
return `@${name}`
})
}
async function fetchSqlServerSourceRows(limit?: number): Promise<OaBoardSourceRows> {
const sqlServer = await loadSqlServer()
const pool = await sqlServer.connect(createSqlServerSourceConfig())
const schema = getSqlServerSourceSchema()
const workflowTable = `${schema}.[ding_Workflow]`
const schemeTable = `${schema}.[ding_WorkflowScheme]`
const taskTable = `${schema}.[ding_WorkflowTask]`
try {
const reviewProcessCode = OA_BOARD_PROCESS_CODES.review
const processCodes = Object.values(OA_BOARD_PROCESS_CODES)
const reviewRequest = pool.request().input('reviewProcessCode', sqlServer.NVarChar(100), reviewProcessCode)
if (limit) reviewRequest.input('workflowLimit', sqlServer.Int, Math.max(1, Math.floor(limit)))
const reviewResult = await reviewRequest.query(`
SELECT ${limit ? 'TOP (@workflowLimit)' : ''} ${sourceWorkflowColumns('[w].')}
FROM ${workflowTable} AS [w]
WHERE [w].[processCode] = @reviewProcessCode
ORDER BY [w].[updateAt] DESC
`)
const reviewWorkflowRows = reviewResult.recordset as SourceWorkflowRow[]
const reviewProcessInstanceIds = getSourceProcessInstanceIds(reviewWorkflowRows)
let reviewSchemeRows: SourceSchemeRow[] = []
if (reviewProcessInstanceIds.length > 0) {
const request = pool.request()
const ids = addSqlServerTextParameters(sqlServer, request, 'reviewProcessInstanceId', reviewProcessInstanceIds)
const result = await request.query(`
SELECT [processInstanceId] AS processInstanceId, [componentType] AS componentType, [name] AS name,
[value] AS value, [extValue] AS extValue
FROM ${schemeTable}
WHERE [processInstanceId] IN (${ids.join(', ')})
`)
reviewSchemeRows = result.recordset as SourceSchemeRow[]
}
const openingCodes = extractOpeningCodes(reviewSchemeRows)
const otherProcessCodes = processCodes.filter((code) => code !== reviewProcessCode)
const linkedFieldNames = buildSourceLinkFieldNames(limit)
let linkedWorkflowRows: SourceWorkflowRow[] = []
if (openingCodes.length > 0) {
const request = pool.request()
const processCodeParameters = addSqlServerTextParameters(sqlServer, request, 'processCode', otherProcessCodes)
const fieldNameParameters = addSqlServerTextParameters(sqlServer, request, 'fieldName', linkedFieldNames)
const openingCodeParameters = addSqlServerTextParameters(sqlServer, request, 'openingCode', openingCodes)
const result = await request.query(`
SELECT DISTINCT ${sourceWorkflowColumns('[w].')}
FROM ${workflowTable} AS [w]
JOIN ${schemeTable} AS [s] ON [s].[processInstanceId] = [w].[processInstanceId]
WHERE [w].[processCode] IN (${processCodeParameters.join(', ')})
AND [s].[name] IN (${fieldNameParameters.join(', ')})
AND [s].[value] IN (${openingCodeParameters.join(', ')})
ORDER BY [w].[updateAt] DESC
`)
linkedWorkflowRows = result.recordset as SourceWorkflowRow[]
}
const workflowRows = uniqueSourceWorkflowRows([...reviewWorkflowRows, ...linkedWorkflowRows])
const processInstanceIds = getSourceProcessInstanceIds(workflowRows)
if (processInstanceIds.length === 0) {
return { workflowRows: [], schemeRows: [], taskRows: [] }
}
const schemeRequest = pool.request()
const schemeIds = addSqlServerTextParameters(sqlServer, schemeRequest, 'schemeProcessInstanceId', processInstanceIds)
const schemeResult = await schemeRequest.query(`
SELECT [processInstanceId] AS processInstanceId, [componentType] AS componentType, [name] AS name,
[value] AS value, [extValue] AS extValue
FROM ${schemeTable}
WHERE [processInstanceId] IN (${schemeIds.join(', ')})
`)
const taskRequest = pool.request()
const taskIds = addSqlServerTextParameters(sqlServer, taskRequest, 'taskProcessInstanceId', processInstanceIds)
const taskResult = await taskRequest.query(`
SELECT [t].[id] AS id, [t].[processInstanceId] AS processInstanceId, [t].[result] AS result,
[t].[activityId] AS activityId, [t].[finishTime] AS finishTime, [t].[createTime] AS createTime,
[t].[userId] AS userId, [t].[taskId] AS taskId, [t].[status] AS status,
[w].[processCode] AS processCode
FROM ${taskTable} AS [t]
JOIN ${workflowTable} AS [w] ON [w].[processInstanceId] = [t].[processInstanceId]
WHERE [t].[processInstanceId] IN (${taskIds.join(', ')})
`)
return {
workflowRows,
schemeRows: schemeResult.recordset as SourceSchemeRow[],
taskRows: taskResult.recordset as SourceTaskRow[],
}
} finally {
await pool.close()
}
}
async function fetchSourceData(limit?: number): Promise<NormalizedFlowRecord[]> {
const sourceRows =
readOaBoardSourceDriver() === 'mssql'
? await fetchSqlServerSourceRows(limit)
: await fetchMysqlSourceRows(limit)
const schemeMap = new Map<string, SourceSchemeRow[]>()
sourceRows.schemeRows.forEach((schemeRow) => {
const current = schemeMap.get(schemeRow.processInstanceId) ?? []
current.push(schemeRow)
schemeMap.set(schemeRow.processInstanceId, current)
})
const taskMap = new Map<string, SourceTaskRow[]>()
sourceRows.taskRows.forEach((taskRow) => {
const current = taskMap.get(taskRow.processInstanceId) ?? []
current.push(taskRow)
taskMap.set(taskRow.processInstanceId, current)
})
const [mappingLookup, nodeRuleLookup] = await Promise.all([
loadActivityMappingLookup(),
loadNodeRuleLookup(),
])
return sourceRows.workflowRows
.map((workflowRow) => {
const flowRecord = buildNormalizedFlowRecord(
workflowRow,
buildFieldSnapshots(schemeMap.get(workflowRow.processInstanceId) ?? []),
buildTaskRecordsWithMappings(
taskMap.get(workflowRow.processInstanceId) ?? [],
OA_BOARD_FLOW_LABELS[inferFlowType(workflowRow.processCode) as OaBoardFlowType] ??
workflowRow.processCode,
mappingLookup,
),
)
if (!flowRecord) return null
return {
...flowRecord,
taskRecords: applyNodeRuleTargets(
flowRecord.taskRecords,
flowRecord.productType,
nodeRuleLookup,
),
}
})
.filter((record): record is NormalizedFlowRecord => Boolean(record))
}
function buildProjectGroups(records: NormalizedFlowRecord[]): PreparedProjectGroup[] {
const groups = new Map<string, PreparedProjectGroup>()
const aliasToBoardKey = new Map<string, string>()