Compare commits
5 Commits
9086aa753f
...
0956bf907f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0956bf907f | ||
|
|
6c730616b8 | ||
|
|
4f4e5fd91a | ||
|
|
ae29f38d24 | ||
|
|
406a8dfd2f |
140
docs/plans/2026-04-05-postgresql-integration-design.md
Normal file
140
docs/plans/2026-04-05-postgresql-integration-design.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# PostgreSQL 集成设计文档
|
||||||
|
|
||||||
|
**日期:** 2026-04-05
|
||||||
|
**状态:** 已批准
|
||||||
|
**分支:** dev-logging
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
将 PostgreSQL 作为第三种可选数据库类型集成到 ERPAuto 中,与现有 MySQL、SQL Server 并列。通过引入 SqlDialect 抽象层,统一管理三种数据库的 SQL 方言差异,同时重构现有 DAO 层消除散落的 `isSqlServer` 判断。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
- PostgreSQL 数据库已通过 SSMA 从 SQL Server 迁移完成,表结构、schema 组织、列名完全一致
|
||||||
|
- 连接信息:`postgresql://admin:***@192.168.31.83:5432/postgres`,数据库 `CompanyDB`
|
||||||
|
- 共 15 个 schema、151 张表,`dbo` schema 包含 ERPAuto 直接使用的表
|
||||||
|
|
||||||
|
## 方案:抽象数据库方言层
|
||||||
|
|
||||||
|
### 1. SqlDialect 接口
|
||||||
|
|
||||||
|
新建 `src/main/types/sql-dialect.types.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface SqlDialect {
|
||||||
|
readonly dbType: DatabaseType
|
||||||
|
|
||||||
|
// 表名引用
|
||||||
|
quoteTableName(schema: string, table: string): string
|
||||||
|
|
||||||
|
// 参数占位符
|
||||||
|
param(index: number): string
|
||||||
|
params(count: number): string
|
||||||
|
|
||||||
|
// SQL 函数
|
||||||
|
currentTimestamp(): string
|
||||||
|
|
||||||
|
// UPSERT
|
||||||
|
upsert(p: {
|
||||||
|
table: string
|
||||||
|
keyColumns: string[]
|
||||||
|
valueColumns: string[]
|
||||||
|
placeholderCount: number
|
||||||
|
startParamIndex: number
|
||||||
|
}): string
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
paginate(p: {
|
||||||
|
sql: string
|
||||||
|
limit: number
|
||||||
|
offset?: number
|
||||||
|
paramIndex: number
|
||||||
|
}): { sql: string; paramIndex: number }
|
||||||
|
|
||||||
|
// 批量限制
|
||||||
|
maxBatchRows(columnsPerRow: number): number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 三种方言实现
|
||||||
|
|
||||||
|
新建 `src/main/services/database/dialects/` 目录:
|
||||||
|
|
||||||
|
| 文件 | 数据库 | param(n) | quoteTableName | currentTimestamp | upsert | paginate |
|
||||||
|
|------|--------|----------|---------------|-----------------|--------|----------|
|
||||||
|
| `mysql-dialect.ts` | MySQL | `?` | `dbo_Table` | `NOW()` | `ON DUPLICATE KEY` | `LIMIT x OFFSET y` |
|
||||||
|
| `sqlserver-dialect.ts` | SQL Server | `@p{n}` | `[dbo].[Table]` | `GETDATE()` | `MERGE` | `OFFSET/FETCH` |
|
||||||
|
| `postgresql-dialect.ts` | PostgreSQL | `${n+1}` | `"dbo"."Table"` | `CURRENT_TIMESTAMP` | `ON CONFLICT` | `LIMIT x OFFSET y` |
|
||||||
|
|
||||||
|
方言工厂 `dialects/index.ts`:
|
||||||
|
```typescript
|
||||||
|
export function createDialect(type: DatabaseType): SqlDialect
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. DAO 层重构
|
||||||
|
|
||||||
|
每个 DAO 新增 `dialect` 成员,替代原有的 `getTableName()`、`buildPlaceholders()` 和所有 `isSqlServer` 分支:
|
||||||
|
|
||||||
|
**删除:**
|
||||||
|
- `getTableName()` 私有方法
|
||||||
|
- `buildPlaceholders()` 私有方法
|
||||||
|
- 所有 `isSqlServer` 局部变量和条件分支
|
||||||
|
- `*_CONFIG` 中的 `TABLE_NAME_SQLSERVER` / `TABLE_NAME_MYSQL` → 合并为 `TABLE_SCHEMA` + `TABLE_NAME`
|
||||||
|
|
||||||
|
**新增:**
|
||||||
|
- `private dialect: SqlDialect | null = null`
|
||||||
|
- `private getDialect(): SqlDialect`
|
||||||
|
|
||||||
|
**涉及 DAO:**
|
||||||
|
- `DiscreteMaterialPlanDAO` — 占位符、表名、批量大小
|
||||||
|
- `MaterialsToBeDeletedDAO` — 占位符、表名、MERGE/ON DUPLICATE KEY → `upsert()`
|
||||||
|
- `MaterialsTypeToBeDeletedDAO` — 同上
|
||||||
|
- `ExtractorOperationHistoryDAO` — 占位符、表名、GETDATE()/NOW() → `currentTimestamp()`、分页 → `paginate()`
|
||||||
|
|
||||||
|
### 4. PostgreSQL 服务层
|
||||||
|
|
||||||
|
新建 `src/main/services/database/postgresql.ts`:
|
||||||
|
- 使用 `pg` 驱动,`Pool` 连接池
|
||||||
|
- 实现 `IDatabaseService` 接口
|
||||||
|
- `query()` 直接传递参数数组给 `pg`
|
||||||
|
- `transaction()` 使用 `client.query('BEGIN/COMMIT/ROLLBACK')`
|
||||||
|
|
||||||
|
### 5. 工厂、配置、TypeORM
|
||||||
|
|
||||||
|
**database/index.ts:** `create()` 新增 `'postgresql'` 分支,新增 `createPostgreSqlConfig()`
|
||||||
|
|
||||||
|
**database.types.ts:** `DatabaseType` 扩展为 `'mysql' | 'sqlserver' | 'postgresql'`,新增 `PostgreSqlConfig`
|
||||||
|
|
||||||
|
**data-source.ts:** TypeORM `type` 映射新增 `'postgres'`
|
||||||
|
|
||||||
|
**config.template.yaml:** 新增 `postgresql` 配置段
|
||||||
|
|
||||||
|
**package.json:** 新增 `pg` 依赖
|
||||||
|
|
||||||
|
## 改动范围
|
||||||
|
|
||||||
|
| 层 | 文件 | 动作 |
|
||||||
|
|---|---|---|
|
||||||
|
| 类型 | `types/database.types.ts` | 修改 |
|
||||||
|
| 方言 | `database/dialects/index.ts` | 新建 |
|
||||||
|
| 方言 | `database/dialects/mysql-dialect.ts` | 新建 |
|
||||||
|
| 方言 | `database/dialects/sqlserver-dialect.ts` | 新建 |
|
||||||
|
| 方言 | `database/dialects/postgresql-dialect.ts` | 新建 |
|
||||||
|
| 服务 | `database/postgresql.ts` | 新建 |
|
||||||
|
| 工厂 | `database/index.ts` | 修改 |
|
||||||
|
| TypeORM | `database/data-source.ts` | 修改 |
|
||||||
|
| DAO | `database/discrete-material-plan-dao.ts` | 重构 |
|
||||||
|
| DAO | `database/materials-to-be-deleted-dao.ts` | 重构 |
|
||||||
|
| DAO | `database/materials-type-to-be-deleted-dao.ts` | 重构 |
|
||||||
|
| DAO | `database/extractor-operation-history-dao.ts` | 重构 |
|
||||||
|
| 配置 | `config.template.yaml` | 修改 |
|
||||||
|
| 依赖 | `package.json` | 修改 |
|
||||||
|
|
||||||
|
共 **4 个新文件 + 10 个修改文件**。
|
||||||
|
|
||||||
|
## 不在范围内
|
||||||
|
|
||||||
|
- IPC 处理器新增(前端暂不需要直接切换 PostgreSQL)
|
||||||
|
- Entity/Repository 的 TypeScript 类型适配(TypeORM 内部处理方言差异)
|
||||||
|
- 数据迁移工具
|
||||||
|
- 前端 UI 变更
|
||||||
1299
docs/plans/2026-04-05-postgresql-integration-plan.md
Normal file
1299
docs/plans/2026-04-05-postgresql-integration-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ import { z } from 'zod'
|
|||||||
/**
|
/**
|
||||||
* 数据库类型枚举
|
* 数据库类型枚举
|
||||||
*/
|
*/
|
||||||
export const databaseTypeSchema = z.enum(['mysql', 'sqlserver'])
|
export const databaseTypeSchema = z.enum(['mysql', 'sqlserver', 'postgresql'])
|
||||||
export type DatabaseType = z.infer<typeof databaseTypeSchema>
|
export type DatabaseType = z.infer<typeof databaseTypeSchema>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,13 +57,27 @@ export const sqlServerConfigSchema = z.object({
|
|||||||
trustServerCertificate: z.boolean().default(true)
|
trustServerCertificate: z.boolean().default(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostgreSQL 配置 Schema
|
||||||
|
*/
|
||||||
|
export const postgresqlConfigSchema = z.object({
|
||||||
|
host: z.string().min(1, 'PostgreSQL host is required'),
|
||||||
|
port: z.number().int().min(1).max(65535).default(5432),
|
||||||
|
database: z.string().min(1, 'PostgreSQL database is required'),
|
||||||
|
username: z.string().min(1, 'PostgreSQL username is required'),
|
||||||
|
password: z.string(),
|
||||||
|
maxPoolSize: z.number().int().min(1).max(100).default(10)
|
||||||
|
})
|
||||||
|
export type PostgreSqlConfigSchema = z.infer<typeof postgresqlConfigSchema>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数据库配置(包含两种数据库的完整配置)
|
* 数据库配置(包含两种数据库的完整配置)
|
||||||
*/
|
*/
|
||||||
export const databaseConfigSchema = z.object({
|
export const databaseConfigSchema = z.object({
|
||||||
activeType: databaseTypeSchema.default('mysql'),
|
activeType: databaseTypeSchema.default('mysql'),
|
||||||
mysql: mysqlConfigSchema,
|
mysql: mysqlConfigSchema,
|
||||||
sqlserver: sqlServerConfigSchema
|
sqlserver: sqlServerConfigSchema,
|
||||||
|
postgresql: postgresqlConfigSchema
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,6 +213,7 @@ export type FullConfig = z.infer<typeof fullConfigSchema>
|
|||||||
export type DatabaseConfig = z.infer<typeof databaseConfigSchema>
|
export type DatabaseConfig = z.infer<typeof databaseConfigSchema>
|
||||||
export type MySqlConfig = z.infer<typeof mysqlConfigSchema>
|
export type MySqlConfig = z.infer<typeof mysqlConfigSchema>
|
||||||
export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
|
export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
|
||||||
|
export type PostgreSqlConfig = z.infer<typeof postgresqlConfigSchema>
|
||||||
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
|
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
|
||||||
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
|
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
|
||||||
export type RustfsConfig = z.infer<typeof rustfsConfigSchema>
|
export type RustfsConfig = z.infer<typeof rustfsConfigSchema>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
/**
|
/**
|
||||||
* Supported database types
|
* Supported database types
|
||||||
*/
|
*/
|
||||||
export type DatabaseType = 'mysql' | 'sqlserver'
|
export type DatabaseType = 'mysql' | 'sqlserver' | 'postgresql'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard query result interface
|
* Standard query result interface
|
||||||
@@ -102,3 +102,15 @@ export interface SqlServerConfig extends DatabaseConfig {
|
|||||||
trustServerCertificate?: boolean
|
trustServerCertificate?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostgreSQL-specific configuration
|
||||||
|
*/
|
||||||
|
export interface PostgreSqlConfig extends DatabaseConfig {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
user: string
|
||||||
|
password: string
|
||||||
|
database: string
|
||||||
|
maxPoolSize?: number
|
||||||
|
}
|
||||||
|
|||||||
80
src/main/types/sql-dialect.types.ts
Normal file
80
src/main/types/sql-dialect.types.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* SQL Dialect Abstraction
|
||||||
|
*
|
||||||
|
* Provides a unified interface for database-specific SQL syntax differences.
|
||||||
|
* Each database type implements this interface to encapsulate:
|
||||||
|
* - Parameter placeholder format
|
||||||
|
* - Table name quoting
|
||||||
|
* - UPSERT syntax
|
||||||
|
* - Pagination syntax
|
||||||
|
* - Current timestamp function
|
||||||
|
* - Batch size limits
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DatabaseType } from './database.types'
|
||||||
|
|
||||||
|
export interface SqlDialect {
|
||||||
|
/** Database type identifier */
|
||||||
|
readonly dbType: DatabaseType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quote a table name with schema prefix
|
||||||
|
* MySQL: dbo_TableName
|
||||||
|
* SQL Server: [dbo].[TableName]
|
||||||
|
* PostgreSQL: "dbo"."TableName"
|
||||||
|
*/
|
||||||
|
quoteTableName(schema: string, table: string): string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get placeholder for parameter at given index (0-based)
|
||||||
|
* MySQL: ?
|
||||||
|
* SQL Server: @p0
|
||||||
|
* PostgreSQL: $1
|
||||||
|
*/
|
||||||
|
param(index: number): string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comma-separated placeholders for count parameters
|
||||||
|
*/
|
||||||
|
params(count: number): string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current timestamp SQL function
|
||||||
|
* MySQL: NOW()
|
||||||
|
* SQL Server: GETDATE()
|
||||||
|
* PostgreSQL: CURRENT_TIMESTAMP
|
||||||
|
*/
|
||||||
|
currentTimestamp(): string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate UPSERT SQL for a single row
|
||||||
|
* MySQL: INSERT ... ON DUPLICATE KEY UPDATE
|
||||||
|
* SQL Server: MERGE ... USING ...
|
||||||
|
* PostgreSQL: INSERT ... ON CONFLICT ... DO UPDATE SET
|
||||||
|
*/
|
||||||
|
upsert(params: {
|
||||||
|
table: string
|
||||||
|
keyColumns: string[]
|
||||||
|
allColumns: string[]
|
||||||
|
startParamIndex: number
|
||||||
|
}): { sql: string; nextParamIndex: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append pagination clause to SQL
|
||||||
|
* MySQL/PostgreSQL: LIMIT x OFFSET y
|
||||||
|
* SQL Server: OFFSET x ROWS FETCH NEXT y ROWS ONLY
|
||||||
|
*/
|
||||||
|
paginate(params: {
|
||||||
|
sql: string
|
||||||
|
limit: number
|
||||||
|
offset?: number
|
||||||
|
paramIndex: number
|
||||||
|
}): { sql: string; nextParamIndex: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum rows per batch given columns per row
|
||||||
|
* SQL Server: ~71 (due to 2100 param limit)
|
||||||
|
* MySQL/PostgreSQL: 1000
|
||||||
|
*/
|
||||||
|
maxBatchRows(columnsPerRow: number): number
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import logger from '../../src/main/services/logger/index'
|
import logger from '../../src/main/services/logger/index'
|
||||||
|
|
||||||
// Mock the logger to avoid noisy output during tests
|
// Mock the logger to avoid noisy output during tests
|
||||||
vi.mock('../../src/main/services/logger/index', () => ({
|
vi.mock('../../src/main/services/logger', () => ({
|
||||||
default: {
|
default: {
|
||||||
debug: vi.fn(),
|
debug: vi.fn(),
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
|
|||||||
@@ -1,36 +1,15 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
CleanerService,
|
||||||
createBatches,
|
createBatches,
|
||||||
getMissingOrders,
|
getMissingOrders,
|
||||||
runWithConcurrency
|
runWithConcurrency
|
||||||
} from '../../src/main/services/erp/cleaner'
|
} from '../../src/main/services/erp/cleaner'
|
||||||
import type { ShouldDeleteParams } from '../../src/main/services/erp/cleaner'
|
|
||||||
|
|
||||||
describe('Cleaner Service (Unit)', () => {
|
describe('Cleaner Service (Unit)', () => {
|
||||||
describe('shouldDeleteMaterial', () => {
|
describe('shouldDeleteMaterial', () => {
|
||||||
// Create a mock cleaner service (no auth needed for this pure function test)
|
// CleanerService constructor requires ErpAuthService, but shouldDeleteMaterial doesn't use it
|
||||||
const mockCleaner = {
|
const cleaner = new CleanerService({} as any)
|
||||||
shouldDeleteMaterial: (params: ShouldDeleteParams): boolean => {
|
|
||||||
const { rowNumber, pendingQty, materialCode, deleteSet } = params
|
|
||||||
|
|
||||||
// Check if material is in delete list
|
|
||||||
if (!deleteSet.has(materialCode)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check row number range (2000-7999 are protected)
|
|
||||||
if (rowNumber >= 2000 && rowNumber < 8000) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check pending quantity (must be empty)
|
|
||||||
if (pendingQty && pendingQty.trim() !== '') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('should skip materials with row number 2000-7999', () => {
|
it('should skip materials with row number 2000-7999', () => {
|
||||||
const testCases = [
|
const testCases = [
|
||||||
@@ -42,7 +21,7 @@ describe('Cleaner Service (Unit)', () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
for (const tc of testCases) {
|
for (const tc of testCases) {
|
||||||
const shouldDelete = mockCleaner.shouldDeleteMaterial({
|
const shouldDelete = cleaner.shouldDeleteMaterial({
|
||||||
rowNumber: tc.rowNumber,
|
rowNumber: tc.rowNumber,
|
||||||
pendingQty: tc.pendingQty,
|
pendingQty: tc.pendingQty,
|
||||||
materialCode: tc.materialCode,
|
materialCode: tc.materialCode,
|
||||||
@@ -53,7 +32,7 @@ describe('Cleaner Service (Unit)', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should skip materials with non-empty pending quantity', () => {
|
it('should skip materials with non-empty pending quantity', () => {
|
||||||
const result = mockCleaner.shouldDeleteMaterial({
|
const result = cleaner.shouldDeleteMaterial({
|
||||||
rowNumber: 100,
|
rowNumber: 100,
|
||||||
pendingQty: '5',
|
pendingQty: '5',
|
||||||
materialCode: 'TEST001',
|
materialCode: 'TEST001',
|
||||||
@@ -64,7 +43,7 @@ describe('Cleaner Service (Unit)', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should skip materials not in delete list', () => {
|
it('should skip materials not in delete list', () => {
|
||||||
const result = mockCleaner.shouldDeleteMaterial({
|
const result = cleaner.shouldDeleteMaterial({
|
||||||
rowNumber: 100,
|
rowNumber: 100,
|
||||||
pendingQty: '',
|
pendingQty: '',
|
||||||
materialCode: 'NOT_IN_LIST',
|
materialCode: 'NOT_IN_LIST',
|
||||||
@@ -84,7 +63,7 @@ describe('Cleaner Service (Unit)', () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
for (const tc of testCases) {
|
for (const tc of testCases) {
|
||||||
const shouldDelete = mockCleaner.shouldDeleteMaterial({
|
const shouldDelete = cleaner.shouldDeleteMaterial({
|
||||||
rowNumber: tc.rowNumber,
|
rowNumber: tc.rowNumber,
|
||||||
pendingQty: tc.pendingQty,
|
pendingQty: tc.pendingQty,
|
||||||
materialCode: tc.materialCode,
|
materialCode: tc.materialCode,
|
||||||
@@ -97,7 +76,7 @@ describe('Cleaner Service (Unit)', () => {
|
|||||||
it('should handle multiple conditions correctly', () => {
|
it('should handle multiple conditions correctly', () => {
|
||||||
// Material in list, valid row, no pending qty = should delete
|
// Material in list, valid row, no pending qty = should delete
|
||||||
expect(
|
expect(
|
||||||
mockCleaner.shouldDeleteMaterial({
|
cleaner.shouldDeleteMaterial({
|
||||||
rowNumber: 100,
|
rowNumber: 100,
|
||||||
pendingQty: '',
|
pendingQty: '',
|
||||||
materialCode: 'TEST001',
|
materialCode: 'TEST001',
|
||||||
@@ -107,7 +86,7 @@ describe('Cleaner Service (Unit)', () => {
|
|||||||
|
|
||||||
// Material in list, protected row, no pending qty = should NOT delete
|
// Material in list, protected row, no pending qty = should NOT delete
|
||||||
expect(
|
expect(
|
||||||
mockCleaner.shouldDeleteMaterial({
|
cleaner.shouldDeleteMaterial({
|
||||||
rowNumber: 7500,
|
rowNumber: 7500,
|
||||||
pendingQty: '',
|
pendingQty: '',
|
||||||
materialCode: 'TEST001',
|
materialCode: 'TEST001',
|
||||||
@@ -117,7 +96,7 @@ describe('Cleaner Service (Unit)', () => {
|
|||||||
|
|
||||||
// Material in list, valid row, has pending qty = should NOT delete
|
// Material in list, valid row, has pending qty = should NOT delete
|
||||||
expect(
|
expect(
|
||||||
mockCleaner.shouldDeleteMaterial({
|
cleaner.shouldDeleteMaterial({
|
||||||
rowNumber: 100,
|
rowNumber: 100,
|
||||||
pendingQty: '10',
|
pendingQty: '10',
|
||||||
materialCode: 'TEST001',
|
materialCode: 'TEST001',
|
||||||
@@ -127,7 +106,7 @@ describe('Cleaner Service (Unit)', () => {
|
|||||||
|
|
||||||
// Material NOT in list = should NOT delete
|
// Material NOT in list = should NOT delete
|
||||||
expect(
|
expect(
|
||||||
mockCleaner.shouldDeleteMaterial({
|
cleaner.shouldDeleteMaterial({
|
||||||
rowNumber: 100,
|
rowNumber: 100,
|
||||||
pendingQty: '',
|
pendingQty: '',
|
||||||
materialCode: 'OTHER',
|
materialCode: 'OTHER',
|
||||||
|
|||||||
@@ -1,59 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* Repository Unit Tests
|
* Repository Unit Tests
|
||||||
*
|
*
|
||||||
* Tests for TypeORM repository patterns.
|
* Behavior-based tests for MaterialsToBeDeletedRepository and DiscreteMaterialPlanRepository.
|
||||||
* Note: These tests mock the database connections.
|
* Verifies correct delegation to TypeORM methods, return values, and safe defaults on error.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { createMockRepository, createMockQueryBuilder } from '../mocks'
|
||||||
|
|
||||||
// Mock TypeORM
|
// --- Shared mock state ---
|
||||||
|
|
||||||
|
let mockRepo: ReturnType<typeof createMockRepository>
|
||||||
|
|
||||||
|
// Mock TypeORM decorators (entities still need them)
|
||||||
vi.mock('typeorm', () => {
|
vi.mock('typeorm', () => {
|
||||||
// Create mock decorator functions
|
const decorator = vi.fn()
|
||||||
const Entity = vi.fn()
|
|
||||||
const PrimaryGeneratedColumn = vi.fn()
|
|
||||||
const Column = vi.fn()
|
|
||||||
const ManyToOne = vi.fn()
|
|
||||||
const OneToMany = vi.fn()
|
|
||||||
const ManyToMany = vi.fn()
|
|
||||||
const JoinColumn = vi.fn()
|
|
||||||
const JoinTable = vi.fn()
|
|
||||||
const CreateDateColumn = vi.fn()
|
|
||||||
const UpdateDateColumn = vi.fn()
|
|
||||||
const DeleteDateColumn = vi.fn()
|
|
||||||
const Index = vi.fn()
|
|
||||||
const Unique = vi.fn()
|
|
||||||
const Check = vi.fn()
|
|
||||||
const Exclusion = vi.fn()
|
|
||||||
const Generated = vi.fn()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
DataSource: vi.fn(() => ({
|
DataSource: vi.fn(() => ({
|
||||||
initialize: vi.fn().mockResolvedValue({}),
|
initialize: vi.fn().mockResolvedValue(undefined),
|
||||||
isInitialized: false,
|
isInitialized: true,
|
||||||
getRepository: vi.fn(),
|
getRepository: vi.fn(() => mockRepo),
|
||||||
destroy: vi.fn()
|
destroy: vi.fn()
|
||||||
})),
|
})),
|
||||||
Repository: vi.fn(),
|
Repository: vi.fn(),
|
||||||
In: vi.fn((arr) => arr),
|
In: vi.fn((arr: unknown[]) => arr),
|
||||||
// Add all the decorators that entities use
|
Entity: decorator,
|
||||||
Entity,
|
PrimaryGeneratedColumn: decorator,
|
||||||
PrimaryGeneratedColumn,
|
Column: decorator,
|
||||||
Column,
|
ManyToOne: decorator,
|
||||||
ManyToOne,
|
OneToMany: decorator,
|
||||||
OneToMany,
|
ManyToMany: decorator,
|
||||||
ManyToMany,
|
JoinColumn: decorator,
|
||||||
JoinColumn,
|
JoinTable: decorator,
|
||||||
JoinTable,
|
CreateDateColumn: decorator,
|
||||||
CreateDateColumn,
|
UpdateDateColumn: decorator,
|
||||||
UpdateDateColumn,
|
DeleteDateColumn: decorator,
|
||||||
DeleteDateColumn,
|
Index: decorator,
|
||||||
Index,
|
Unique: decorator,
|
||||||
Unique,
|
Check: decorator,
|
||||||
Check,
|
Exclusion: decorator,
|
||||||
Exclusion,
|
Generated: decorator,
|
||||||
Generated,
|
|
||||||
// Other TypeORM exports
|
|
||||||
Between: vi.fn(),
|
Between: vi.fn(),
|
||||||
LessThan: vi.fn(),
|
LessThan: vi.fn(),
|
||||||
LessThanOrEqual: vi.fn(),
|
LessThanOrEqual: vi.fn(),
|
||||||
@@ -76,40 +62,577 @@ vi.mock('../../src/main/services/logger', () => ({
|
|||||||
}))
|
}))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../src/main/services/database/data-source', () => ({
|
||||||
|
getDataSource: vi.fn(() => ({
|
||||||
|
isInitialized: true,
|
||||||
|
initialize: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getRepository: vi.fn(() => mockRepo)
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MaterialsToBeDeletedRepository
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
describe('MaterialsToBeDeletedRepository', () => {
|
describe('MaterialsToBeDeletedRepository', () => {
|
||||||
beforeEach(() => {
|
let MaterialsToBeDeletedRepository: typeof import(
|
||||||
|
'../../src/main/services/database/repositories/MaterialsToBeDeletedRepository'
|
||||||
|
).MaterialsToBeDeletedRepository
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockRepo = createMockRepository()
|
||||||
|
const mod = await import(
|
||||||
|
'../../src/main/services/database/repositories/MaterialsToBeDeletedRepository'
|
||||||
|
)
|
||||||
|
MaterialsToBeDeletedRepository = mod.MaterialsToBeDeletedRepository
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should be defined', async () => {
|
afterEach(() => {
|
||||||
const { MaterialsToBeDeletedRepository } =
|
vi.resetModules()
|
||||||
await import('../../src/main/services/database/repositories/MaterialsToBeDeletedRepository')
|
|
||||||
expect(MaterialsToBeDeletedRepository).toBeDefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should create repository instance', async () => {
|
// --- upsert ---
|
||||||
const { MaterialsToBeDeletedRepository } =
|
|
||||||
await import('../../src/main/services/database/repositories/MaterialsToBeDeletedRepository')
|
it('upsert: creates new entity when not found', async () => {
|
||||||
const repo = new MaterialsToBeDeletedRepository()
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
expect(repo).toBeDefined()
|
mockRepo.findOne!.mockResolvedValue(null)
|
||||||
|
mockRepo.create!.mockReturnValue({ materialCode: 'MAT01', managerName: 'Alice' })
|
||||||
|
|
||||||
|
const result = await repo.upsert('MAT01', 'Alice')
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { materialCode: 'MAT01' } })
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith({ materialCode: 'MAT01', managerName: 'Alice' })
|
||||||
|
expect(mockRepo.save).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upsert: updates existing entity', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
const existing = { materialCode: 'MAT01', managerName: 'Bob' }
|
||||||
|
mockRepo.findOne!.mockResolvedValue(existing)
|
||||||
|
|
||||||
|
const result = await repo.upsert('MAT01', 'Alice')
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(existing.managerName).toBe('Alice')
|
||||||
|
expect(mockRepo.save).toHaveBeenCalledWith(existing)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upsert: returns false on error', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.findOne!.mockRejectedValue(new Error('db fail'))
|
||||||
|
|
||||||
|
const result = await repo.upsert('MAT01', 'Alice')
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- upsertBatch ---
|
||||||
|
|
||||||
|
it('upsertBatch: processes valid materials', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.findOne!.mockResolvedValue(null)
|
||||||
|
mockRepo.create!.mockImplementation((data) => data)
|
||||||
|
|
||||||
|
const stats = await repo.upsertBatch([
|
||||||
|
{ materialCode: 'M1', managerName: 'A' },
|
||||||
|
{ materialCode: 'M2', managerName: 'B' }
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(stats.total).toBe(2)
|
||||||
|
expect(stats.success).toBe(2)
|
||||||
|
expect(stats.failed).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upsertBatch: skips empty materialCodes', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
|
||||||
|
const stats = await repo.upsertBatch([
|
||||||
|
{ materialCode: '', managerName: 'A' },
|
||||||
|
{ materialCode: ' ', managerName: 'B' },
|
||||||
|
{ materialCode: 'M1', managerName: 'C' }
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(stats.total).toBe(3)
|
||||||
|
expect(stats.success).toBe(1)
|
||||||
|
expect(stats.failed).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upsertBatch: returns stats with partial failures', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.findOne!.mockRejectedValue(new Error('fail'))
|
||||||
|
|
||||||
|
const stats = await repo.upsertBatch([{ materialCode: 'M1', managerName: 'A' }])
|
||||||
|
|
||||||
|
expect(stats.total).toBe(1)
|
||||||
|
expect(stats.failed).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- getAllMaterialCodes ---
|
||||||
|
|
||||||
|
it('getAllMaterialCodes: returns Set via QueryBuilder', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
const qb = createMockQueryBuilder({
|
||||||
|
result: [{ materialCode: 'M1' }, { materialCode: 'M2' }]
|
||||||
|
})
|
||||||
|
mockRepo.createQueryBuilder!.mockReturnValue(qb)
|
||||||
|
|
||||||
|
const result = await repo.getAllMaterialCodes()
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(Set)
|
||||||
|
expect(result.size).toBe(2)
|
||||||
|
expect(result.has('M1')).toBe(true)
|
||||||
|
expect(result.has('M2')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getAllMaterialCodes: returns empty Set on error', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.createQueryBuilder!.mockImplementation(() => {
|
||||||
|
throw new Error('db fail')
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await repo.getAllMaterialCodes()
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(Set)
|
||||||
|
expect(result.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- getAllRecords ---
|
||||||
|
|
||||||
|
it('getAllRecords: returns ordered records', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
const records = [{ materialCode: 'M1' }, { materialCode: 'M2' }]
|
||||||
|
mockRepo.find!.mockResolvedValue(records)
|
||||||
|
|
||||||
|
const result = await repo.getAllRecords()
|
||||||
|
|
||||||
|
expect(result).toEqual(records)
|
||||||
|
expect(mockRepo.find).toHaveBeenCalledWith({
|
||||||
|
order: { managerName: 'ASC', materialCode: 'ASC' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getAllRecords: returns empty array on error', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.find!.mockRejectedValue(new Error('db fail'))
|
||||||
|
|
||||||
|
const result = await repo.getAllRecords()
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- getByManager ---
|
||||||
|
|
||||||
|
it('getByManager: filters by managerName', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
const records = [{ materialCode: 'M1', managerName: 'Alice' }]
|
||||||
|
mockRepo.find!.mockResolvedValue(records)
|
||||||
|
|
||||||
|
const result = await repo.getByManager('Alice')
|
||||||
|
|
||||||
|
expect(result).toEqual(records)
|
||||||
|
expect(mockRepo.find).toHaveBeenCalledWith({
|
||||||
|
where: { managerName: 'Alice' },
|
||||||
|
order: { materialCode: 'ASC' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getByManager: returns empty array on error', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.find!.mockRejectedValue(new Error('db fail'))
|
||||||
|
|
||||||
|
const result = await repo.getByManager('Alice')
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- getManagers ---
|
||||||
|
|
||||||
|
it('getManagers: returns distinct names via QueryBuilder', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
const qb = createMockQueryBuilder({
|
||||||
|
result: [{ managerName: 'Alice' }, { managerName: 'Bob' }]
|
||||||
|
})
|
||||||
|
mockRepo.createQueryBuilder!.mockReturnValue(qb)
|
||||||
|
|
||||||
|
const result = await repo.getManagers()
|
||||||
|
|
||||||
|
expect(result).toEqual(['Alice', 'Bob'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getManagers: returns empty array on error', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.createQueryBuilder!.mockImplementation(() => {
|
||||||
|
throw new Error('db fail')
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await repo.getManagers()
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- deleteByMaterialCode ---
|
||||||
|
|
||||||
|
it('deleteByMaterialCode: returns true when affected > 0', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.delete!.mockResolvedValue({ affected: 1 })
|
||||||
|
|
||||||
|
const result = await repo.deleteByMaterialCode('M1')
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(mockRepo.delete).toHaveBeenCalledWith({ materialCode: 'M1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteByMaterialCode: returns false when affected is 0', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.delete!.mockResolvedValue({ affected: 0 })
|
||||||
|
|
||||||
|
const result = await repo.deleteByMaterialCode('M1')
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteByMaterialCode: returns false on error', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.delete!.mockRejectedValue(new Error('db fail'))
|
||||||
|
|
||||||
|
const result = await repo.deleteByMaterialCode('M1')
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- deleteByMaterialCodes ---
|
||||||
|
|
||||||
|
it('deleteByMaterialCodes: returns 0 for empty array', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
|
||||||
|
const result = await repo.deleteByMaterialCodes([])
|
||||||
|
|
||||||
|
expect(result).toBe(0)
|
||||||
|
expect(mockRepo.delete).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteByMaterialCodes: returns affected count', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.delete!.mockResolvedValue({ affected: 3 })
|
||||||
|
|
||||||
|
const result = await repo.deleteByMaterialCodes(['M1', 'M2', 'M3'])
|
||||||
|
|
||||||
|
expect(result).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteByMaterialCodes: returns 0 on error', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.delete!.mockRejectedValue(new Error('db fail'))
|
||||||
|
|
||||||
|
const result = await repo.deleteByMaterialCodes(['M1'])
|
||||||
|
|
||||||
|
expect(result).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- exists ---
|
||||||
|
|
||||||
|
it('exists: returns true when count > 0', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.count!.mockResolvedValue(1)
|
||||||
|
|
||||||
|
const result = await repo.exists('M1')
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exists: returns false when count is 0', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.count!.mockResolvedValue(0)
|
||||||
|
|
||||||
|
const result = await repo.exists('M1')
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exists: returns false on error', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.count!.mockRejectedValue(new Error('db fail'))
|
||||||
|
|
||||||
|
const result = await repo.exists('M1')
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- countAll ---
|
||||||
|
|
||||||
|
it('countAll: returns count', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.count!.mockResolvedValue(42)
|
||||||
|
|
||||||
|
const result = await repo.countAll()
|
||||||
|
|
||||||
|
expect(result).toBe(42)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('countAll: returns 0 on error', async () => {
|
||||||
|
const repo = new MaterialsToBeDeletedRepository()
|
||||||
|
mockRepo.count!.mockRejectedValue(new Error('db fail'))
|
||||||
|
|
||||||
|
const result = await repo.countAll()
|
||||||
|
|
||||||
|
expect(result).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DiscreteMaterialPlanRepository
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
describe('DiscreteMaterialPlanRepository', () => {
|
describe('DiscreteMaterialPlanRepository', () => {
|
||||||
beforeEach(() => {
|
let DiscreteMaterialPlanRepository: typeof import(
|
||||||
|
'../../src/main/services/database/repositories/DiscreteMaterialPlanRepository'
|
||||||
|
).DiscreteMaterialPlanRepository
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockRepo = createMockRepository()
|
||||||
|
const mod = await import(
|
||||||
|
'../../src/main/services/database/repositories/DiscreteMaterialPlanRepository'
|
||||||
|
)
|
||||||
|
DiscreteMaterialPlanRepository = mod.DiscreteMaterialPlanRepository
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should be defined', async () => {
|
afterEach(() => {
|
||||||
const { DiscreteMaterialPlanRepository } =
|
vi.resetModules()
|
||||||
await import('../../src/main/services/database/repositories/DiscreteMaterialPlanRepository')
|
|
||||||
expect(DiscreteMaterialPlanRepository).toBeDefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should create repository instance', async () => {
|
// --- queryAll ---
|
||||||
const { DiscreteMaterialPlanRepository } =
|
|
||||||
await import('../../src/main/services/database/repositories/DiscreteMaterialPlanRepository')
|
it('queryAll: returns find results', async () => {
|
||||||
const repo = new DiscreteMaterialPlanRepository()
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
expect(repo).toBeDefined()
|
const records = [{ sourceNumber: 'S1' }, { sourceNumber: 'S2' }]
|
||||||
|
mockRepo.find!.mockResolvedValue(records)
|
||||||
|
|
||||||
|
const result = await repo.queryAll()
|
||||||
|
|
||||||
|
expect(result).toEqual(records)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queryAll: returns empty array on error', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
mockRepo.find!.mockRejectedValue(new Error('db fail'))
|
||||||
|
|
||||||
|
const result = await repo.queryAll()
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- queryAllDistinctByMaterialCode ---
|
||||||
|
|
||||||
|
it('queryAllDistinctByMaterialCode: calls repo.query() with raw SQL', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
const mockQueryResult = [{ MaterialCode: 'M1', rn: 1 }]
|
||||||
|
mockRepo = { ...createMockRepository(), query: vi.fn().mockResolvedValue(mockQueryResult) }
|
||||||
|
// Re-import to pick up new mockRepo
|
||||||
|
vi.resetModules()
|
||||||
|
const mod = await import(
|
||||||
|
'../../src/main/services/database/repositories/DiscreteMaterialPlanRepository'
|
||||||
|
)
|
||||||
|
const freshRepo = new mod.DiscreteMaterialPlanRepository()
|
||||||
|
|
||||||
|
const result = await freshRepo.queryAllDistinctByMaterialCode()
|
||||||
|
|
||||||
|
expect(result).toEqual(mockQueryResult)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queryAllDistinctByMaterialCode: returns empty array on error', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
mockRepo = {
|
||||||
|
...createMockRepository(),
|
||||||
|
query: vi.fn().mockRejectedValue(new Error('db fail'))
|
||||||
|
}
|
||||||
|
vi.resetModules()
|
||||||
|
const mod = await import(
|
||||||
|
'../../src/main/services/database/repositories/DiscreteMaterialPlanRepository'
|
||||||
|
)
|
||||||
|
const freshRepo = new mod.DiscreteMaterialPlanRepository()
|
||||||
|
|
||||||
|
const result = await freshRepo.queryAllDistinctByMaterialCode()
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- queryBySourceNumbers ---
|
||||||
|
|
||||||
|
it('queryBySourceNumbers: returns empty array for empty input', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
|
||||||
|
const result = await repo.queryBySourceNumbers([])
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
expect(mockRepo.find).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queryBySourceNumbers: batches in groups of 2000', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
// Create 2500 source numbers to trigger 2 batches
|
||||||
|
const sourceNumbers = Array.from({ length: 2500 }, (_, i) => `S${i}`)
|
||||||
|
mockRepo.find!.mockResolvedValue([])
|
||||||
|
|
||||||
|
await repo.queryBySourceNumbers(sourceNumbers)
|
||||||
|
|
||||||
|
// Should be called twice: once for 2000, once for 500
|
||||||
|
expect(mockRepo.find).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queryBySourceNumbers: returns combined results', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
const records = [{ sourceNumber: 'S1' }, { sourceNumber: 'S2' }]
|
||||||
|
mockRepo.find!.mockResolvedValue(records)
|
||||||
|
|
||||||
|
const result = await repo.queryBySourceNumbers(['S1', 'S2'])
|
||||||
|
|
||||||
|
expect(result).toEqual(records)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queryBySourceNumbers: returns empty array on error', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
mockRepo.find!.mockRejectedValue(new Error('db fail'))
|
||||||
|
|
||||||
|
const result = await repo.queryBySourceNumbers(['S1'])
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- queryBySourceNumbersDistinct ---
|
||||||
|
|
||||||
|
it('queryBySourceNumbersDistinct: returns empty array for empty input', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
|
||||||
|
const result = await repo.queryBySourceNumbersDistinct([])
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queryBySourceNumbersDistinct: calls repo.query() per batch', async () => {
|
||||||
|
mockRepo = { ...createMockRepository(), query: vi.fn().mockResolvedValue([{ M: 'X' }]) }
|
||||||
|
vi.resetModules()
|
||||||
|
const mod = await import(
|
||||||
|
'../../src/main/services/database/repositories/DiscreteMaterialPlanRepository'
|
||||||
|
)
|
||||||
|
const repo = new mod.DiscreteMaterialPlanRepository()
|
||||||
|
|
||||||
|
const result = await repo.queryBySourceNumbersDistinct(['S1', 'S2'])
|
||||||
|
|
||||||
|
expect(result.length).toBeGreaterThan(0)
|
||||||
|
expect((mockRepo as Record<string, unknown>).query).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queryBySourceNumbersDistinct: returns empty array on error', async () => {
|
||||||
|
mockRepo = {
|
||||||
|
...createMockRepository(),
|
||||||
|
query: vi.fn().mockRejectedValue(new Error('db fail'))
|
||||||
|
}
|
||||||
|
vi.resetModules()
|
||||||
|
const mod = await import(
|
||||||
|
'../../src/main/services/database/repositories/DiscreteMaterialPlanRepository'
|
||||||
|
)
|
||||||
|
const repo = new mod.DiscreteMaterialPlanRepository()
|
||||||
|
|
||||||
|
const result = await repo.queryBySourceNumbersDistinct(['S1'])
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- queryBySourceNumber ---
|
||||||
|
|
||||||
|
it('queryBySourceNumber: calls find with where clause', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
const records = [{ sourceNumber: 'S1', planNumber: 'P1' }]
|
||||||
|
mockRepo.find!.mockResolvedValue(records)
|
||||||
|
|
||||||
|
const result = await repo.queryBySourceNumber('S1')
|
||||||
|
|
||||||
|
expect(result).toEqual(records)
|
||||||
|
expect(mockRepo.find).toHaveBeenCalledWith({ where: { sourceNumber: 'S1' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queryBySourceNumber: returns empty array on error', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
mockRepo.find!.mockRejectedValue(new Error('db fail'))
|
||||||
|
|
||||||
|
const result = await repo.queryBySourceNumber('S1')
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- queryByPlanNumber ---
|
||||||
|
|
||||||
|
it('queryByPlanNumber: calls find with where clause', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
const records = [{ sourceNumber: 'S1', planNumber: 'P1' }]
|
||||||
|
mockRepo.find!.mockResolvedValue(records)
|
||||||
|
|
||||||
|
const result = await repo.queryByPlanNumber('P1')
|
||||||
|
|
||||||
|
expect(result).toEqual(records)
|
||||||
|
expect(mockRepo.find).toHaveBeenCalledWith({ where: { planNumber: 'P1' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queryByPlanNumber: returns empty array on error', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
mockRepo.find!.mockRejectedValue(new Error('db fail'))
|
||||||
|
|
||||||
|
const result = await repo.queryByPlanNumber('P1')
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- queryByPlanNumbers ---
|
||||||
|
|
||||||
|
it('queryByPlanNumbers: returns empty array for empty input', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
|
||||||
|
const result = await repo.queryByPlanNumbers([])
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
expect(mockRepo.find).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queryByPlanNumbers: calls find with In()', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
const records = [{ planNumber: 'P1' }, { planNumber: 'P2' }]
|
||||||
|
mockRepo.find!.mockResolvedValue(records)
|
||||||
|
|
||||||
|
const result = await repo.queryByPlanNumbers(['P1', 'P2'])
|
||||||
|
|
||||||
|
expect(result).toEqual(records)
|
||||||
|
expect(mockRepo.find).toHaveBeenCalledWith({
|
||||||
|
where: { planNumber: ['P1', 'P2'] } // In() mock returns the array as-is
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queryByPlanNumbers: returns empty array on error', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
mockRepo.find!.mockRejectedValue(new Error('db fail'))
|
||||||
|
|
||||||
|
const result = await repo.queryByPlanNumbers(['P1'])
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- countAll ---
|
||||||
|
|
||||||
|
it('countAll: returns count', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
mockRepo.count!.mockResolvedValue(99)
|
||||||
|
|
||||||
|
const result = await repo.countAll()
|
||||||
|
|
||||||
|
expect(result).toBe(99)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('countAll: returns 0 on error', async () => {
|
||||||
|
const repo = new DiscreteMaterialPlanRepository()
|
||||||
|
mockRepo.count!.mockRejectedValue(new Error('db fail'))
|
||||||
|
|
||||||
|
const result = await repo.countAll()
|
||||||
|
|
||||||
|
expect(result).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user