5 Commits

Author SHA1 Message Date
Misaka
0956bf907f feat(db): add SqlDialect interface and PostgreSQL type definitions
- Add 'postgresql' to DatabaseType union in database.types.ts
- Add PostgreSqlConfig interface extending DatabaseConfig
- Add postgresqlConfigSchema Zod schema with host, port, database,
  username, password, and maxPoolSize fields
- Add 'postgresql' to databaseConfigSchema and type exports
- Create SqlDialect interface with methods for quoteTableName,
  param, params, currentTimestamp, upsert, paginate, maxBatchRows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:19:49 +08:00
Misaka
6c730616b8 docs: add PostgreSQL integration implementation plan
6-task TDD plan covering SqlDialect abstraction, PostgreSqlService,
DAO refactoring, and config/factory integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:14:33 +08:00
Misaka
4f4e5fd91a docs: add PostgreSQL integration design document
Design for integrating PostgreSQL as a third database option using
a SqlDialect abstraction layer to unify SQL dialect differences
across MySQL, SQL Server, and PostgreSQL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:09:39 +08:00
Misaka
ae29f38d24 fix(test): improve logger mock path and add behavior-based repository tests
- Fix logger-performance test mock path to use bare module specifier
- Replace meaningless "should be defined" assertions in repositories test
  with behavior-based tests covering upsert, batch operations, queries,
  deletes, and error handling for both MaterialsToBeDeletedRepository
  and DiscreteMaterialPlanRepository

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 08:16:40 +08:00
Misaka
406a8dfd2f fix(test): replace duplicated business logic in cleaner test with real CleanerService
The shouldDeleteMaterial tests had a mockCleaner that reimplemented the
production logic inline, meaning bugs in the real code would never be caught.
Now uses an actual CleanerService instance instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 08:16:02 +08:00
8 changed files with 2146 additions and 98 deletions

View 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 变更

File diff suppressed because it is too large Load Diff

View File

@@ -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>
/**
@@ -57,13 +57,27 @@ export const sqlServerConfigSchema = z.object({
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({
activeType: databaseTypeSchema.default('mysql'),
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 MySqlConfig = z.infer<typeof mysqlConfigSchema>
export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
export type PostgreSqlConfig = z.infer<typeof postgresqlConfigSchema>
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
export type RustfsConfig = z.infer<typeof rustfsConfigSchema>

View File

@@ -8,7 +8,7 @@
/**
* Supported database types
*/
export type DatabaseType = 'mysql' | 'sqlserver'
export type DatabaseType = 'mysql' | 'sqlserver' | 'postgresql'
/**
* Standard query result interface
@@ -102,3 +102,15 @@ export interface SqlServerConfig extends DatabaseConfig {
trustServerCertificate?: boolean
}
}
/**
* PostgreSQL-specific configuration
*/
export interface PostgreSqlConfig extends DatabaseConfig {
host: string
port: number
user: string
password: string
database: string
maxPoolSize?: number
}

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

View File

@@ -15,7 +15,7 @@ import {
import logger from '../../src/main/services/logger/index'
// Mock the logger to avoid noisy output during tests
vi.mock('../../src/main/services/logger/index', () => ({
vi.mock('../../src/main/services/logger', () => ({
default: {
debug: vi.fn(),
info: vi.fn(),

View File

@@ -1,36 +1,15 @@
import { describe, it, expect } from 'vitest'
import {
CleanerService,
createBatches,
getMissingOrders,
runWithConcurrency
} from '../../src/main/services/erp/cleaner'
import type { ShouldDeleteParams } from '../../src/main/services/erp/cleaner'
describe('Cleaner Service (Unit)', () => {
describe('shouldDeleteMaterial', () => {
// Create a mock cleaner service (no auth needed for this pure function test)
const mockCleaner = {
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
}
}
// CleanerService constructor requires ErpAuthService, but shouldDeleteMaterial doesn't use it
const cleaner = new CleanerService({} as any)
it('should skip materials with row number 2000-7999', () => {
const testCases = [
@@ -42,7 +21,7 @@ describe('Cleaner Service (Unit)', () => {
]
for (const tc of testCases) {
const shouldDelete = mockCleaner.shouldDeleteMaterial({
const shouldDelete = cleaner.shouldDeleteMaterial({
rowNumber: tc.rowNumber,
pendingQty: tc.pendingQty,
materialCode: tc.materialCode,
@@ -53,7 +32,7 @@ describe('Cleaner Service (Unit)', () => {
})
it('should skip materials with non-empty pending quantity', () => {
const result = mockCleaner.shouldDeleteMaterial({
const result = cleaner.shouldDeleteMaterial({
rowNumber: 100,
pendingQty: '5',
materialCode: 'TEST001',
@@ -64,7 +43,7 @@ describe('Cleaner Service (Unit)', () => {
})
it('should skip materials not in delete list', () => {
const result = mockCleaner.shouldDeleteMaterial({
const result = cleaner.shouldDeleteMaterial({
rowNumber: 100,
pendingQty: '',
materialCode: 'NOT_IN_LIST',
@@ -84,7 +63,7 @@ describe('Cleaner Service (Unit)', () => {
]
for (const tc of testCases) {
const shouldDelete = mockCleaner.shouldDeleteMaterial({
const shouldDelete = cleaner.shouldDeleteMaterial({
rowNumber: tc.rowNumber,
pendingQty: tc.pendingQty,
materialCode: tc.materialCode,
@@ -97,7 +76,7 @@ describe('Cleaner Service (Unit)', () => {
it('should handle multiple conditions correctly', () => {
// Material in list, valid row, no pending qty = should delete
expect(
mockCleaner.shouldDeleteMaterial({
cleaner.shouldDeleteMaterial({
rowNumber: 100,
pendingQty: '',
materialCode: 'TEST001',
@@ -107,7 +86,7 @@ describe('Cleaner Service (Unit)', () => {
// Material in list, protected row, no pending qty = should NOT delete
expect(
mockCleaner.shouldDeleteMaterial({
cleaner.shouldDeleteMaterial({
rowNumber: 7500,
pendingQty: '',
materialCode: 'TEST001',
@@ -117,7 +96,7 @@ describe('Cleaner Service (Unit)', () => {
// Material in list, valid row, has pending qty = should NOT delete
expect(
mockCleaner.shouldDeleteMaterial({
cleaner.shouldDeleteMaterial({
rowNumber: 100,
pendingQty: '10',
materialCode: 'TEST001',
@@ -127,7 +106,7 @@ describe('Cleaner Service (Unit)', () => {
// Material NOT in list = should NOT delete
expect(
mockCleaner.shouldDeleteMaterial({
cleaner.shouldDeleteMaterial({
rowNumber: 100,
pendingQty: '',
materialCode: 'OTHER',

View File

@@ -1,59 +1,45 @@
/**
* Repository Unit Tests
*
* Tests for TypeORM repository patterns.
* Note: These tests mock the database connections.
* Behavior-based tests for MaterialsToBeDeletedRepository and DiscreteMaterialPlanRepository.
* 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', () => {
// Create mock decorator functions
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()
const decorator = vi.fn()
return {
DataSource: vi.fn(() => ({
initialize: vi.fn().mockResolvedValue({}),
isInitialized: false,
getRepository: vi.fn(),
initialize: vi.fn().mockResolvedValue(undefined),
isInitialized: true,
getRepository: vi.fn(() => mockRepo),
destroy: vi.fn()
})),
Repository: vi.fn(),
In: vi.fn((arr) => arr),
// Add all the decorators that entities use
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
ManyToMany,
JoinColumn,
JoinTable,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
Unique,
Check,
Exclusion,
Generated,
// Other TypeORM exports
In: vi.fn((arr: unknown[]) => arr),
Entity: decorator,
PrimaryGeneratedColumn: decorator,
Column: decorator,
ManyToOne: decorator,
OneToMany: decorator,
ManyToMany: decorator,
JoinColumn: decorator,
JoinTable: decorator,
CreateDateColumn: decorator,
UpdateDateColumn: decorator,
DeleteDateColumn: decorator,
Index: decorator,
Unique: decorator,
Check: decorator,
Exclusion: decorator,
Generated: decorator,
Between: vi.fn(),
LessThan: 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', () => {
beforeEach(() => {
let MaterialsToBeDeletedRepository: typeof import(
'../../src/main/services/database/repositories/MaterialsToBeDeletedRepository'
).MaterialsToBeDeletedRepository
beforeEach(async () => {
vi.clearAllMocks()
mockRepo = createMockRepository()
const mod = await import(
'../../src/main/services/database/repositories/MaterialsToBeDeletedRepository'
)
MaterialsToBeDeletedRepository = mod.MaterialsToBeDeletedRepository
})
it('should be defined', async () => {
const { MaterialsToBeDeletedRepository } =
await import('../../src/main/services/database/repositories/MaterialsToBeDeletedRepository')
expect(MaterialsToBeDeletedRepository).toBeDefined()
afterEach(() => {
vi.resetModules()
})
it('should create repository instance', async () => {
const { MaterialsToBeDeletedRepository } =
await import('../../src/main/services/database/repositories/MaterialsToBeDeletedRepository')
// --- upsert ---
it('upsert: creates new entity when not found', async () => {
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', () => {
beforeEach(() => {
let DiscreteMaterialPlanRepository: typeof import(
'../../src/main/services/database/repositories/DiscreteMaterialPlanRepository'
).DiscreteMaterialPlanRepository
beforeEach(async () => {
vi.clearAllMocks()
mockRepo = createMockRepository()
const mod = await import(
'../../src/main/services/database/repositories/DiscreteMaterialPlanRepository'
)
DiscreteMaterialPlanRepository = mod.DiscreteMaterialPlanRepository
})
it('should be defined', async () => {
const { DiscreteMaterialPlanRepository } =
await import('../../src/main/services/database/repositories/DiscreteMaterialPlanRepository')
expect(DiscreteMaterialPlanRepository).toBeDefined()
afterEach(() => {
vi.resetModules()
})
it('should create repository instance', async () => {
const { DiscreteMaterialPlanRepository } =
await import('../../src/main/services/database/repositories/DiscreteMaterialPlanRepository')
// --- queryAll ---
it('queryAll: returns find results', async () => {
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)
})
})