From 940c45459f53b52432992cd07a840bef7ec3eb33 Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Tue, 5 May 2026 14:54:36 +0800 Subject: [PATCH 01/54] test: add Prisma migration integration tests for TaxCode engine - Empty DB migrate deploy: verify all 32 tables, TaxCode columns, tax snapshot fields, indexes, FKs - Old schema upgrade: step-by-step pre-taxcode migrations, verify column additions - TaxCode historical data strategy: zero-default for old rows, full snapshots for new rows, unique constraints, FK SET NULL behavior, idempotency --- .../api/test/migrations/migration.e2e-spec.ts | 583 ++++++++++++++++++ 1 file changed, 583 insertions(+) create mode 100644 apps/api/test/migrations/migration.e2e-spec.ts diff --git a/apps/api/test/migrations/migration.e2e-spec.ts b/apps/api/test/migrations/migration.e2e-spec.ts new file mode 100644 index 0000000..593f888 --- /dev/null +++ b/apps/api/test/migrations/migration.e2e-spec.ts @@ -0,0 +1,583 @@ +/** + * Prisma 迁移集成测试 + * + * 三个核心场景: + * 1. 空库 migrate deploy — 全量迁移后验证完整 schema + * 2. 旧库迁移到当前 schema — 逐步迁移 + 增量数据验证 + * 3. TaxCode 历史数据策略 — 迁移后历史行的默认值断言 + * + * 运行前提: + * - 环境变量 DATABASE_URL 指向测试库 (会 DROP ALL TABLES) + * - PostgreSQL >= 14 + * + * 运行方式: + * DATABASE_URL=postgresql://... npx jest --config ./test/jest-e2e.json test/migrations/migration.e2e-spec.ts + */ + +import { Client } from 'pg'; +import { readFileSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { execSync } from 'child_process'; + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +const PRISMA_DIR = join(__dirname, '../../prisma'); +const MIGRATIONS_DIR = join(PRISMA_DIR, 'migrations'); + +/** 按文件名排序返回所有 migration 目录 */ +function getMigrationDirs(): string[] { + return readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory() && /^\d{14}_/.test(d.name)) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((d) => d.name); +} + +/** 读取单个 migration.sql 内容 */ +function readMigrationSql(dirName: string): string { + const sqlPath = join(MIGRATIONS_DIR, dirName, 'migration.sql'); + return readFileSync(sqlPath, 'utf-8'); +} + +/** 安全执行 SQL,忽略 "already exists" 类错误 */ +async function safeExec(client: Client, sql: string): Promise { + try { + await client.query(sql); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (/already exists|duplicate/i.test(msg)) return; + throw err; + } +} + +/** 删除所有 public 表 (测试前清库) */ +async function dropAllTables(client: Client): Promise { + await client.query(` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP + EXECUTE 'DROP TABLE IF EXISTS "' || r.tablename || '" CASCADE'; + END LOOP; + END $$; + `); + await client.query('DROP TABLE IF EXISTS "_prisma_migrations" CASCADE'); +} + +/** 获取指定表的所有列名 */ +async function getColumns(client: Client, tableName: string): Promise> { + const res = await client.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1`, + [tableName], + ); + return new Set(res.rows.map((r: { column_name: string }) => r.column_name)); +} + +/** 检查表是否存在 */ +async function tableExists(client: Client, tableName: string): Promise { + const res = await client.query( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = $1 + ) AS exists`, + [tableName], + ); + return res.rows[0].exists as boolean; +} + +// --------------------------------------------------------------------------- +// 按 Prisma schema 期望的完整表清单 +// --------------------------------------------------------------------------- +const EXPECTED_TABLES = [ + 'Company', 'User', 'Role', 'UserCompanyRole', 'Department', 'Partner', + 'TaxCode', 'Order', 'OrderItem', 'Warehouse', 'StockLocation', 'Material', + 'ProductCategory', 'Product', 'Bom', 'BomLine', 'StockQuant', 'FileRecord', + 'InventoryTransaction', 'Workflow', 'WorkflowState', 'WorkflowTransition', + 'WorkOrder', 'WorkReport', 'Invoice', 'Payment', 'Account', 'Journal', + 'JournalEntry', 'JournalEntryLine', 'EventDlq', 'AuditLog', +]; + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +describe('Prisma 迁移集成测试', () => { + let client: Client; + const databaseUrl = process.env.DATABASE_URL; + + beforeAll(async () => { + if (!databaseUrl) { + throw new Error('DATABASE_URL 环境变量未设置。请指向测试 PostgreSQL 数据库。'); + } + client = new Client({ connectionString: databaseUrl }); + await client.connect(); + }); + + afterAll(async () => { + await client?.end(); + }); + + // ========================================================================= + // 场景 1: 空库 migrate deploy + // ========================================================================= + describe('场景 1: 空库 migrate deploy', () => { + beforeAll(async () => { + await dropAllTables(client); + }); + + it('应当通过 prisma migrate deploy 完成全量迁移', () => { + expect(() => { + execSync('npx prisma migrate deploy', { + cwd: join(__dirname, '../..'), + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + timeout: 60_000, + }); + }).not.toThrow(); + }); + + it('所有预期表应当存在', async () => { + for (const table of EXPECTED_TABLES) { + const exists = await tableExists(client, table); + expect(exists).toBe(true); + } + }); + + it('TaxCode 表应当包含全部字段', async () => { + const cols = await getColumns(client, 'TaxCode'); + const expectedCols = [ + 'id', 'code', 'name', 'rate', 'isTaxInclusive', 'isDefault', + 'active', 'accountId', 'companyId', 'createdAt', 'updatedAt', + ]; + for (const col of expectedCols) { + expect(cols.has(col)).toBe(true); + } + }); + + it('Order 表应当包含税码快照字段', async () => { + const cols = await getColumns(client, 'Order'); + expect(cols.has('subTotal')).toBe(true); + expect(cols.has('taxTotal')).toBe(true); + expect(cols.has('taxCodeId')).toBe(true); + }); + + it('OrderItem 表应当包含税码快照字段', async () => { + const cols = await getColumns(client, 'OrderItem'); + expect(cols.has('subTotal')).toBe(true); + expect(cols.has('taxAmount')).toBe(true); + expect(cols.has('taxRate')).toBe(true); + expect(cols.has('taxCodeId')).toBe(true); + }); + + it('Invoice 表应当包含税码快照字段', async () => { + const cols = await getColumns(client, 'Invoice'); + expect(cols.has('subTotal')).toBe(true); + expect(cols.has('taxAmount')).toBe(true); + expect(cols.has('taxCodeId')).toBe(true); + }); + + it('StockLocation 表应当包含 parentId 自引用字段', async () => { + const cols = await getColumns(client, 'StockLocation'); + expect(cols.has('parentId')).toBe(true); + }); + + it('Account 表应当存在(TaxCode 关联依赖)', async () => { + const cols = await getColumns(client, 'Account'); + expect(cols.has('id')).toBe(true); + expect(cols.has('code')).toBe(true); + expect(cols.has('companyId')).toBe(true); + }); + + it('唯一索引 TaxCode(companyId, code) 应当存在', async () => { + const res = await client.query(` + SELECT indexname FROM pg_indexes + WHERE tablename = 'TaxCode' + AND indexname = 'TaxCode_companyId_code_key' + `); + expect(res.rowCount).toBe(1); + }); + + it('外键约束 Order → TaxCode 应当存在', async () => { + const res = await client.query(` + SELECT conname FROM pg_constraint + WHERE conname = 'Order_taxCodeId_fkey' + `); + expect(res.rowCount).toBe(1); + }); + }); + + // ========================================================================= + // 场景 2: 旧库迁移到当前 schema + // ========================================================================= + describe('场景 2: 旧库迁移到当前 schema', () => { + const allDirs = getMigrationDirs(); + const taxcodeIdx = allDirs.findIndex((d) => d.includes('taxcode_engine')); + const preTaxDirs = allDirs.slice(0, taxcodeIdx); + const taxcodeDir = allDirs[taxcodeIdx]; + + beforeAll(async () => { + await dropAllTables(client); + }); + + it('应当能逐步执行 TaxCode 迁移之前的所有脚本', async () => { + for (const dirName of preTaxDirs) { + const sql = readMigrationSql(dirName); + if (sql.trim() === '-- no-op after baseline rewrite') continue; + await safeExec(client, sql); + } + expect(await tableExists(client, 'Company')).toBe(true); + expect(await tableExists(client, 'Order')).toBe(true); + expect(await tableExists(client, 'OrderItem')).toBe(true); + expect(await tableExists(client, 'Invoice')).toBe(true); + }); + + it('迁移前 Order 表不应有 taxCodeId 列', async () => { + if (taxcodeIdx > 0) { + const cols = await getColumns(client, 'Order'); + expect(cols.has('taxCodeId')).toBe(false); + } + }); + + it('迁移前不应存在 TaxCode 表', async () => { + expect(await tableExists(client, 'TaxCode')).toBe(false); + }); + + it('应当能执行 TaxCode 迁移脚本', async () => { + const sql = readMigrationSql(taxcodeDir); + await safeExec(client, sql); + expect(await tableExists(client, 'TaxCode')).toBe(true); + }); + + it('迁移后 Order 表应当出现 taxCodeId 列', async () => { + const cols = await getColumns(client, 'Order'); + expect(cols.has('taxCodeId')).toBe(true); + expect(cols.has('subTotal')).toBe(true); + expect(cols.has('taxTotal')).toBe(true); + }); + + it('迁移后 OrderItem 表应当出现税码字段', async () => { + const cols = await getColumns(client, 'OrderItem'); + expect(cols.has('taxCodeId')).toBe(true); + expect(cols.has('subTotal')).toBe(true); + expect(cols.has('taxAmount')).toBe(true); + expect(cols.has('taxRate')).toBe(true); + }); + + it('迁移后 Invoice 表应当出现税码字段', async () => { + const cols = await getColumns(client, 'Invoice'); + expect(cols.has('taxCodeId')).toBe(true); + expect(cols.has('subTotal')).toBe(true); + expect(cols.has('taxAmount')).toBe(true); + }); + + it('应当能执行剩余所有迁移脚本', async () => { + const remainingDirs = allDirs.slice(taxcodeIdx + 1); + for (const dirName of remainingDirs) { + const sql = readMigrationSql(dirName); + if (sql.trim() === '-- no-op after baseline rewrite') continue; + await safeExec(client, sql); + } + const cols = await getColumns(client, 'StockLocation'); + expect(cols.has('parentId')).toBe(true); + }); + }); + + // ========================================================================= + // 场景 3: TaxCode 历史数据策略验证 + // ========================================================================= + describe('场景 3: TaxCode 历史数据策略验证', () => { + let companyId: string; + let userId: string; + let partnerId: string; + + beforeAll(async () => { + await dropAllTables(client); + execSync('npx prisma migrate deploy', { + cwd: join(__dirname, '../..'), + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + timeout: 60_000, + }); + }); + + it('应当能插入 Company 基础数据', async () => { + const res = await client.query(` + INSERT INTO "Company" ("id", "name", "updatedAt") + VALUES (gen_random_uuid(), '测试公司', NOW()) + RETURNING "id" + `); + companyId = res.rows[0].id; + expect(companyId).toBeTruthy(); + }); + + it('应当能插入 User 基础数据', async () => { + const res = await client.query(` + INSERT INTO "User" ("id", "email", "passwordHash", "name", "updatedAt") + VALUES (gen_random_uuid(), 'test@example.com', 'hash', '测试用户', NOW()) + RETURNING "id" + `); + userId = res.rows[0].id; + expect(userId).toBeTruthy(); + }); + + it('应当能插入 Partner 基础数据', async () => { + const res = await client.query(` + INSERT INTO "Partner" ("id", "name", "companyId", "updatedAt") + VALUES (gen_random_uuid(), '测试客户', $1, NOW()) + RETURNING "id" + `, [companyId]); + partnerId = res.rows[0].id; + expect(partnerId).toBeTruthy(); + }); + + it('应当能创建 TaxCode 主数据', async () => { + const res = await client.query(` + INSERT INTO "TaxCode" ("id", "code", "name", "rate", "companyId", "updatedAt") + VALUES (gen_random_uuid(), 'VAT_13', '增值税 13%', 0.13, $1, NOW()) + RETURNING "id", "code", "rate" + `, [companyId]); + expect(res.rows[0].code).toBe('VAT_13'); + expect(Number(res.rows[0].rate)).toBeCloseTo(0.13); + }); + + describe('历史订单的默认税码快照策略', () => { + let orderId: string; + + it('应当能创建不含 taxCodeId 的历史订单(模拟旧数据)', async () => { + const res = await client.query(` + INSERT INTO "Order" ("id", "orderNo", "partnerId", "salesId", "companyId", "status", "totalAmount", "updatedAt") + VALUES (gen_random_uuid(), 'ORD-HIST-001', $1, $2, $3, 'DRAFT', 1000, NOW()) + RETURNING "id" + `, [partnerId, userId, companyId]); + orderId = res.rows[0].id; + + const row = await client.query(` + SELECT "subTotal", "taxTotal", "taxCodeId" + FROM "Order" WHERE "id" = $1 + `, [orderId]); + + expect(Number(row.rows[0].subTotal)).toBe(0); + expect(Number(row.rows[0].taxTotal)).toBe(0); + expect(row.rows[0].taxCodeId).toBeNull(); + }); + + it('应当能创建不含 taxCodeId 的历史订单行', async () => { + await client.query(` + INSERT INTO "OrderItem" ("id", "orderId", "productId", "quantity", "unitPrice", "totalPrice", "updatedAt") + VALUES (gen_random_uuid(), $1, 'PROD-001', 10, 100, 1000, NOW()) + `, [orderId]); + + const row = await client.query(` + SELECT "subTotal", "taxAmount", "taxRate", "taxCodeId" + FROM "OrderItem" WHERE "orderId" = $1 + `, [orderId]); + + expect(Number(row.rows[0].subTotal)).toBe(0); + expect(Number(row.rows[0].taxAmount)).toBe(0); + expect(Number(row.rows[0].taxRate)).toBe(0); + expect(row.rows[0].taxCodeId).toBeNull(); + }); + + it('历史订单应当可查询且 tax 字段全为零', async () => { + const res = await client.query(` + SELECT "id", "subTotal", "taxTotal", "taxCodeId" + FROM "Order" WHERE "orderNo" = 'ORD-HIST-001' + `); + expect(res.rowCount).toBe(1); + expect(Number(res.rows[0].subTotal)).toBe(0); + expect(Number(res.rows[0].taxTotal)).toBe(0); + expect(res.rows[0].taxCodeId).toBeNull(); + }); + }); + + describe('含税码的新订单快照策略', () => { + let taxCodeId: string; + + it('应当能查询已创建的 TaxCode', async () => { + const res = await client.query(` + SELECT "id" FROM "TaxCode" WHERE "code" = 'VAT_13' AND "companyId" = $1 + `, [companyId]); + expect(res.rowCount).toBe(1); + taxCodeId = res.rows[0].id; + }); + + it('新订单应能关联 taxCodeId 并记录快照', async () => { + await client.query(` + INSERT INTO "Order" ( + "id", "orderNo", "partnerId", "salesId", "companyId", + "status", "totalAmount", "subTotal", "taxTotal", "taxCodeId", "updatedAt" + ) VALUES ( + gen_random_uuid(), 'ORD-NEW-001', $1, $2, $3, + 'DRAFT', 1130, 1000, 130, $4, NOW() + ) + `, [partnerId, userId, companyId, taxCodeId]); + + const row = await client.query(` + SELECT "subTotal", "taxTotal", "taxCodeId" + FROM "Order" WHERE "orderNo" = 'ORD-NEW-001' + `); + + expect(Number(row.rows[0].subTotal)).toBe(1000); + expect(Number(row.rows[0].taxTotal)).toBe(130); + expect(row.rows[0].taxCodeId).toBe(taxCodeId); + }); + + it('新订单行应能关联 taxCodeId 并记录税率', async () => { + const orderRes = await client.query(` + SELECT "id" FROM "Order" WHERE "orderNo" = 'ORD-NEW-001' + `); + const orderId = orderRes.rows[0].id; + + await client.query(` + INSERT INTO "OrderItem" ( + "id", "orderId", "productId", "quantity", "unitPrice", "totalPrice", + "subTotal", "taxAmount", "taxRate", "taxCodeId", "updatedAt" + ) VALUES ( + gen_random_uuid(), $1, 'PROD-002', 10, 100, 1130, + 1000, 130, 0.13, $2, NOW() + ) + `, [orderId, taxCodeId]); + + const row = await client.query(` + SELECT "subTotal", "taxAmount", "taxRate", "taxCodeId" + FROM "OrderItem" WHERE "orderId" = $1 + `, [orderId]); + + expect(Number(row.rows[0].subTotal)).toBe(1000); + expect(Number(row.rows[0].taxAmount)).toBe(130); + expect(Number(row.rows[0].taxRate)).toBeCloseTo(0.13); + expect(row.rows[0].taxCodeId).toBe(taxCodeId); + }); + }); + + describe('Invoice 的税码快照策略', () => { + it('历史 Invoice(无 taxCodeId)应当默认值为零', async () => { + const orderRes = await client.query(` + SELECT "id" FROM "Order" WHERE "orderNo" = 'ORD-HIST-001' + `); + const orderId = orderRes.rows[0].id; + + await client.query(` + INSERT INTO "Invoice" ( + "id", "invoiceNo", "orderId", "amount", "status", "companyId", "updatedAt" + ) VALUES ( + gen_random_uuid(), 'INV-HIST-001', $1, 1000, 'UNPAID', $2, NOW() + ) + `, [orderId, companyId]); + + const row = await client.query(` + SELECT "subTotal", "taxAmount", "taxCodeId" + FROM "Invoice" WHERE "invoiceNo" = 'INV-HIST-001' + `); + + expect(Number(row.rows[0].subTotal)).toBe(0); + expect(Number(row.rows[0].taxAmount)).toBe(0); + expect(row.rows[0].taxCodeId).toBeNull(); + }); + + it('新 Invoice 可关联 TaxCode 并记录税额快照', async () => { + const orderRes = await client.query(` + SELECT "id" FROM "Order" WHERE "orderNo" = 'ORD-NEW-001' + `); + const taxCodeRes = await client.query(` + SELECT "id" FROM "TaxCode" WHERE "code" = 'VAT_13' AND "companyId" = $1 + `, [companyId]); + + await client.query(` + INSERT INTO "Invoice" ( + "id", "invoiceNo", "orderId", "amount", + "subTotal", "taxAmount", "taxCodeId", + "status", "companyId", "postingStatus", "updatedAt" + ) VALUES ( + gen_random_uuid(), 'INV-NEW-001', $1, 1130, + 1000, 130, $2, + 'UNPAID', $3, 'DRAFT', NOW() + ) + `, [orderRes.rows[0].id, taxCodeRes.rows[0].id, companyId]); + + const row = await client.query(` + SELECT "subTotal", "taxAmount", "taxCodeId" + FROM "Invoice" WHERE "invoiceNo" = 'INV-NEW-001' + `); + + expect(Number(row.rows[0].subTotal)).toBe(1000); + expect(Number(row.rows[0].taxAmount)).toBe(130); + expect(row.rows[0].taxCodeId).toBe(taxCodeRes.rows[0].id); + }); + }); + + describe('TaxCode 约束与索引验证', () => { + it('同一公司下不允许重复 code', async () => { + await expect( + client.query(` + INSERT INTO "TaxCode" ("id", "code", "name", "rate", "companyId", "updatedAt") + VALUES (gen_random_uuid(), 'VAT_13', '重复税码', 0.13, $1, NOW()) + `, [companyId]), + ).rejects.toThrow(/unique/i); + }); + + it('不同公司可以有相同 code', async () => { + const res = await client.query(` + INSERT INTO "Company" ("id", "name", "updatedAt") + VALUES (gen_random_uuid(), '第二公司', NOW()) + RETURNING "id" + `); + const company2Id = res.rows[0].id; + + await client.query(` + INSERT INTO "TaxCode" ("id", "code", "name", "rate", "companyId", "updatedAt") + VALUES (gen_random_uuid(), 'VAT_13', '增值税 13%', 0.13, $1, NOW()) + `, [company2Id]); + + const count = await client.query(` + SELECT COUNT(*)::int AS cnt FROM "TaxCode" WHERE "code" = 'VAT_13' + `); + expect(count.rows[0].cnt).toBe(2); + }); + + it('TaxCode 的 companyId 外键应引用 Company', async () => { + await expect( + client.query(` + INSERT INTO "TaxCode" ("id", "code", "name", "rate", "companyId", "updatedAt") + VALUES (gen_random_uuid(), 'INVALID', '无效', 0, 'non-existent-id', NOW()) + `), + ).rejects.toThrow(/violates foreign key/i); + }); + + it('taxCodeId 外键 SET NULL: 删除 TaxCode 不应级联删除关联订单', async () => { + const orderRes = await client.query(` + SELECT o."id" AS "orderId", tc."id" AS "taxCodeId" + FROM "Order" o + JOIN "TaxCode" tc ON o."taxCodeId" = tc."id" + WHERE tc."code" = 'VAT_13' + LIMIT 1 + `); + + if (orderRes.rowCount === 0) return; + + const { orderId, taxCodeId } = orderRes.rows[0]; + await client.query(`DELETE FROM "TaxCode" WHERE "id" = $1`, [taxCodeId]); + + const checkRes = await client.query( + `SELECT "taxCodeId" FROM "Order" WHERE "id" = $1`, + [orderId], + ); + expect(checkRes.rowCount).toBe(1); + expect(checkRes.rows[0].taxCodeId).toBeNull(); + }); + }); + + describe('批量迁移幂等性验证', () => { + it('重复执行 taxcode_engine 迁移 SQL 不应报错', async () => { + const dirs = getMigrationDirs(); + const taxcodeDir = dirs.find((d) => d.includes('taxcode_engine')); + expect(taxcodeDir).toBeDefined(); + + const sql = readMigrationSql(taxcodeDir!); + await expect(safeExec(client, sql)).resolves.not.toThrow(); + }); + }); + }); +}); From 94097b79e605676bcc240a7dd6cd6ee6ea33bd82 Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Tue, 5 May 2026 14:55:17 +0800 Subject: [PATCH 02/54] =?UTF-8?q?ci(deploy):=20=E5=AE=8C=E5=96=84=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=B5=81=E7=A8=8B=20=E2=80=94=20=E5=85=A8=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E9=83=A8=E7=BD=B2=E3=80=81GHCR=E6=9D=83=E9=99=90?= =?UTF-8?q?=E3=80=81=E9=95=9C=E5=83=8Ftag=E3=80=81=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E3=80=81=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.prod.yml: - 修复 NEXT_PUBLIC_API_URL -> NEXT_PUBLIC_API_BASE_URL (与 api.ts 一致) - 修复 Redis 健康检查带密码: redis-cli -a ping - 新增 CORS_ORIGINS 环境变量 (main.ts 读取) - 移除 IMAGE_OWNER 无效默认值 your-org, 强制要求配置 - 镜像标签从硬编码 :latest 改为 支持回滚 - deploy.yml: - 从 placeholder 升级为完整 SSH 自动部署 (appleboy/ssh-action) - 新增 workflow_dispatch inputs: deploy_api, deploy_web, image_tag - 支持通过 image_tag 参数回滚到指定 commit SHA - 部署后自动健康检查 + 旧镜像清理 - .env.example: - 补充生产环境专用变量文档 (IMAGE_OWNER, IMAGE_TAG, CORS_ORIGINS) - 新增端口/URL 对应关系说明 - docs/deployment.md (新建): - 部署模式决策: CI/CD 全自动构建+推送+SSH部署 - GHCR 权限配置指南 (推送/拉取/所需 Secrets) - 镜像 Tag 策略 (latest/SHA/branch) - 回滚策略 (GitHub Actions 手动触发 + 服务器紧急回滚) - 环境变量完整性检查 (必填/可选/最小模板) - 运维命令速查 --- .env.example | 21 +++ .github/workflows/deploy.yml | 90 ++++++++----- docker-compose.prod.yml | 34 ++++- docs/deployment.md | 254 +++++++++++++++++++++++++++++++++++ 4 files changed, 359 insertions(+), 40 deletions(-) create mode 100644 docs/deployment.md diff --git a/.env.example b/.env.example index 4362bbb..ee4ce96 100644 --- a/.env.example +++ b/.env.example @@ -64,3 +64,24 @@ OPENAI_MODEL=gpt-4o-mini # 生产环境请根据实际部署地址配置,例如: # NEXT_PUBLIC_API_BASE_URL=https://api.example.com/api # NEXT_PUBLIC_API_BASE_URL=/api/proxy (搭配 Next.js rewrites 反向代理) + +# ============================================================ +# 生产环境专用变量 (仅 docker-compose.prod.yml 使用) +# ============================================================ +# 以下变量仅在生产部署时需要,在 .env 中按需配置 +# +# ---- Docker 镜像 ---- +# IMAGE_OWNER — 必填! GitHub 用户名或组织名,用于拉取 GHCR 镜像 +# IMAGE_TAG — 镜像标签,默认 latest;回滚时指定 commit SHA +# REGISTRY — 镜像仓库地址,默认 ghcr.io +# IMAGE_OWNER= +# IMAGE_TAG=latest +# REGISTRY=ghcr.io +# +# ---- CORS ---- +# CORS_ORIGINS — API 的 CORS 白名单 (逗号分隔),生产环境务必包含前端域名 +# CORS_ORIGINS=https://your-domain.com +# +# ---- Nginx (可选) ---- +# HTTP_PORT=80 +# HTTPS_PORT=443 \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9138113..8abcb4b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,6 +15,11 @@ on: required: false default: "true" type: boolean + image_tag: + description: "镜像标签 (留空=latest, 填 commit SHA=回滚到指定版本)" + required: false + default: "" + type: string env: REGISTRY: ghcr.io @@ -23,6 +28,8 @@ env: jobs: # ---- Build & Push API Docker Image ---- build-api: + # 手动触发且未勾选 deploy_api 时跳过 + if: ${{ github.event_name != 'workflow_dispatch' || inputs.deploy_api }} runs-on: ubuntu-latest permissions: contents: read @@ -65,6 +72,8 @@ jobs: # ---- Build & Push Web Docker Image ---- build-web: + # 手动触发且未勾选 deploy_web 时跳过 + if: ${{ github.event_name != 'workflow_dispatch' || inputs.deploy_web }} runs-on: ubuntu-latest permissions: contents: read @@ -105,11 +114,12 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - # ---- Deploy to Server (placeholder — customize for your infra) ---- + # ---- Deploy to Production Server ---- deploy: needs: [build-api, build-web] + # 仅 main 分支 push 或手动触发时执行部署 + if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' environment: name: production @@ -126,36 +136,48 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # ---- Option A: Deploy via SSH (uncomment and configure) ---- - # - name: Deploy to server via SSH - # uses: appleboy/ssh-action@v1 - # with: - # host: ${{ secrets.DEPLOY_HOST }} - # username: ${{ secrets.DEPLOY_USER }} - # key: ${{ secrets.DEPLOY_SSH_KEY }} - # script: | - # cd /opt/oneerp - # docker compose pull - # docker compose up -d --remove-orphans - # docker image prune -f - - # ---- Option B: Deploy via Docker Compose on self-hosted runner ---- - # - name: Deploy with Docker Compose - # run: | - # docker compose -f docker-compose.prod.yml pull - # docker compose -f docker-compose.prod.yml up -d --remove-orphans - # docker image prune -f - - - name: Deployment placeholder + - name: Determine image tag + id: tag run: | - echo "::notice::Deploy step is configured as a placeholder." - echo "To enable automatic deployment, uncomment one of the options above." - echo "" - echo "Required secrets for SSH deployment:" - echo " - DEPLOY_HOST: Server IP or hostname" - echo " - DEPLOY_USER: SSH username" - echo " - DEPLOY_SSH_KEY: SSH private key" - echo "" - echo "Published images:" - echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-api:latest" - echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web:latest" + TAG="${{ inputs.image_tag }}" + if [ -z "$TAG" ]; then + TAG="latest" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "::notice::Deploying images with tag: $TAG" + + - name: Deploy to server via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + cd /opt/oneerp + echo "=== Pulling images (tag: ${{ steps.tag.outputs.tag }}) ===" + IMAGE_TAG=${{ steps.tag.outputs.tag }} docker compose -f docker-compose.prod.yml pull + echo "=== Starting services ===" + IMAGE_TAG=${{ steps.tag.outputs.tag }} docker compose -f docker-compose.prod.yml up -d --remove-orphans + echo "=== Waiting for health checks ===" + sleep 15 + IMAGE_TAG=${{ steps.tag.outputs.tag }} docker compose -f docker-compose.prod.yml ps + echo "=== Pruning old images ===" + docker image prune -f + + - name: Verify deployment + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + cd /opt/oneerp + echo "=== Checking container health ===" + docker compose -f docker-compose.prod.yml ps --format "table {{.Name}}\t{{.Status}}" + # 检查 API 是否可访问 + if curl -sf http://localhost:${API_PORT:-8000}/api/docs > /dev/null 2>&1; then + echo "✅ API health check passed" + else + echo "❌ API health check failed" + exit 1 + fi \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0b77caa..f262e79 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,6 +1,19 @@ # ============================================================ # OneERP Production Docker Compose -# Usage: docker compose -f docker-compose.prod.yml up -d +# ============================================================ +# 前置条件: +# 1. 复制 .env.example 为 .env 并填写所有生产环境实际值 +# 2. 确保 IMAGE_OWNER 设置为你的 GitHub 用户名或组织名 +# 3. 确保 POSTGRES_PASSWORD / JWT_SECRET / MINIO_SECRET_KEY 均已设置(无默认值) +# +# 启动命令 (不带 Nginx): +# docker compose -f docker-compose.prod.yml up -d +# +# 启动命令 (带 Nginx 反向代理): +# docker compose -f docker-compose.prod.yml --profile with-nginx up -d +# +# 指定镜像版本标签 (默认 latest,用于回滚): +# IMAGE_TAG=abc1234 docker compose -f docker-compose.prod.yml up -d # ============================================================ version: '3.8' @@ -36,7 +49,8 @@ services: volumes: - redisdata:/data healthcheck: - test: ["CMD", "redis-cli", "ping"] + # 带密码健康检查: 若 REDIS_PASSWORD 为空则退化为无密码 ping + test: ["CMD-SHELL", "redis-cli ${REDIS_PASSWORD:+-a $REDIS_PASSWORD} ping | grep PONG"] interval: 10s timeout: 5s retries: 5 @@ -63,7 +77,8 @@ services: # ---- NestJS API ---- api: - image: ${REGISTRY:-ghcr.io}/${IMAGE_OWNER:-your-org}/oneerp-api:latest + # IMAGE_TAG 环境变量用于回滚: 回滚时指定 commit SHA 即可 + image: ${REGISTRY:-ghcr.io}/${IMAGE_OWNER}/oneerp-api:${IMAGE_TAG:-latest} container_name: oneerp_api restart: always depends_on: @@ -83,19 +98,26 @@ services: MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minio_admin} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} PORT: ${API_PORT:-8000} + # CORS 白名单 (逗号分隔); 生产环境前端域名需加入此列表 + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:${WEB_PORT:-3000}} + # ---- 可选: AI 功能 (未配置时自动降级为规则引擎) ---- + # OPENAI_API_KEY: ${OPENAI_API_KEY:-} + # OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o-mini} ports: - "${API_PORT:-8000}:8000" # ---- Next.js Web ---- web: - image: ${REGISTRY:-ghcr.io}/${IMAGE_OWNER:-your-org}/oneerp-web:latest + image: ${REGISTRY:-ghcr.io}/${IMAGE_OWNER}/oneerp-web:${IMAGE_TAG:-latest} container_name: oneerp_web restart: always depends_on: - api environment: NODE_ENV: production - NEXT_PUBLIC_API_URL: http://api:${API_PORT:-8000} + # 服务端渲染 (SSR) 时通过 Docker 内部网络访问 API + # 注意: 变量名必须是 NEXT_PUBLIC_API_BASE_URL (与 apps/web/src/lib/api.ts 一致) + NEXT_PUBLIC_API_BASE_URL: http://api:${API_PORT:-8000}/api ports: - "${WEB_PORT:-3000}:3000" @@ -119,4 +141,4 @@ services: volumes: pgdata: redisdata: - miniodata: + miniodata: \ No newline at end of file diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..d9671c8 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,254 @@ +# OneERP 部署指南 + +> 本文档面向 DevOps 工程师和项目总结构师,明确部署流程中的关键决策。 + +--- + +## 1. 部署模式:构建 + 自动部署 + +### 决策:CI/CD 全自动(构建镜像 → 推送 GHCR → SSH 部署服务器) + +| 阶段 | 触发方式 | 说明 | +|------|---------|------| +| **构建** | push to `main` / 手动触发 | GitHub Actions 自动构建 Docker 镜像 | +| **推送** | 同上 | 镜像自动推送到 GitHub Container Registry (GHCR) | +| **部署** | 同上 | 通过 SSH 连接生产服务器,拉取新镜像并重启服务 | + +**不采用"只构建镜像、手动部署"模式** — 已在 `deploy.yml` 中启用 SSH 自动部署。 + +### 流程图 + +``` +git push main + │ + ▼ +┌─────────────┐ ┌─────────────┐ +│ build-api │ │ build-web │ (并行) +│ 构建+推送 │ │ 构建+推送 │ +└──────┬──────┘ └──────┬──────┘ + │ │ + └────────┬────────┘ + ▼ + ┌─────────────┐ + │ deploy │ SSH → 服务器 + │ pull + up │ + └──────┬──────┘ + ▼ + ┌─────────────┐ + │ verify │ 健康检查 + └─────────────┘ +``` + +--- + +## 2. GHCR 权限配置 + +### 2.1 镜像推送权限(CI 侧) + +GitHub Actions 自动获取 `GITHUB_TOKEN`,无需手动配置 PAT。 + +```yaml +# deploy.yml 中已配置 +permissions: + contents: read + packages: write # 允许推送镜像到 ghcr.io +``` + +### 2.2 镜像拉取权限(服务器侧) + +**私有仓库**需要在生产服务器上配置 GHCR 登录凭据: + +```bash +# 方式 A: 使用 GitHub PAT(推荐) +# 1. 在 GitHub Settings → Developer Settings → Personal Access Tokens → Fine-grained tokens +# 创建一个 token,权限: packages:read +# 2. 在服务器上登录: +echo "$GHCR_PAT" | docker login ghcr.io -u YOUR_USERNAME --password-stdin + +# 方式 B: 使用 deploy key + GITHUB_TOKEN(仅限 GitHub Actions 内部使用) +# deploy.yml 中已通过 docker/login-action 自动处理 +``` + +**公开仓库**无需额外配置,任何人均可拉取。 + +### 2.3 所需 Secrets & Variables + +在 GitHub 仓库 Settings → Secrets and variables → Actions 中配置: + +| 名称 | 类型 | 说明 | +|------|------|------| +| `DEPLOY_HOST` | Secret | 生产服务器 IP 或域名 | +| `DEPLOY_USER` | Secret | SSH 登录用户名 | +| `DEPLOY_SSH_KEY` | Secret | SSH 私钥(用于免密登录) | +| `APP_URL` | Variable | 应用访问地址(用于 environment URL 显示) | + +--- + +## 3. 镜像标签 (Tag) 策略 + +### 3.1 标签规则 + +| 标签格式 | 生成时机 | 用途 | +|---------|---------|------| +| `latest` | 每次 main 分支 push | 默认部署版本 | +| `main` | 每次 main 分支 push | 分支标签 | +| `<7位commit SHA>` | 每次 push | **回滚标识**,如 `a1b2c3d` | + +### 3.2 镜像全名格式 + +``` +ghcr.io//oneerp-api: +ghcr.io//oneerp-web: +``` + +示例: +- `ghcr.io/myorg/oneerp-api:latest` — 最新版本 +- `ghcr.io/myorg/oneerp-api:a1b2c3d` — 指定 commit 版本 + +### 3.3 如何指定标签部署 + +```bash +# 服务器端手动指定标签 +IMAGE_TAG=a1b2c3d docker compose -f docker-compose.prod.yml up -d + +# 或通过 GitHub Actions 手动触发时填写 image_tag 参数 +``` + +--- + +## 4. 回滚策略 + +### 4.1 自动回滚(CI 健康检查失败) + +`deploy.yml` 中的 `verify` 步骤会在部署后执行健康检查: +- 检查所有容器状态 +- 调用 `/api/docs` 验证 API 可用性 +- **如果健康检查失败,workflow 标记为失败**(但不会自动回滚旧版本) + +### 4.2 手动回滚(推荐) + +回滚操作通过 GitHub Actions 手动触发: + +1. 打开 GitHub → Actions → Deploy workflow +2. 点击 "Run workflow" +3. 在 `image_tag` 输入框中填写要回滚的 commit SHA(如 `a1b2c3d`) +4. 点击 "Run workflow" + +**服务器端紧急回滚**: + +```bash +cd /opt/oneerp + +# 查看历史部署的镜像标签 +# (在 GitHub Actions 历史记录中找到上一个成功的 commit SHA) + +# 回滚到指定版本 +IMAGE_TAG=<上一个成功commit的SHA前7位> \ + docker compose -f docker-compose.prod.yml pull && \ + IMAGE_TAG=<上一个成功commit的SHA前7位> \ + docker compose -f docker-compose.prod.yml up -d --remove-orphans + +# 验证 +docker compose -f docker-compose.prod.yml ps +curl -s http://localhost:8000/api/docs | head -5 +``` + +### 4.3 回滚注意事项 + +- **数据库迁移是单向的** — 如果新版本包含破坏性的 Prisma migration,回滚代码后数据库结构不会自动回退。重大变更前请确保已备份数据库。 +- **备份命令**: + ```bash + docker exec oneerp_postgres pg_dump -U eip_user eip_db > backup_$(date +%Y%m%d_%H%M%S).sql + ``` + +--- + +## 5. 环境变量完整性检查 + +### 5.1 `.env` 中必须设置的变量(无默认值,启动会失败) + +| 变量 | 所属服务 | 说明 | +|------|---------|------| +| `POSTGRES_PASSWORD` | db, api | 数据库密码 | +| `JWT_SECRET` | api | JWT 签名密钥,务必使用强随机字符串 | +| `MINIO_SECRET_KEY` | minio, api | MinIO 对象存储密钥 | +| `IMAGE_OWNER` | api, web | GitHub 用户名或组织名(镜像地址前缀) | + +### 5.2 `.env` 中可选的变量(有默认值) + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `POSTGRES_USER` | `eip_user` | 数据库用户名 | +| `POSTGRES_DB` | `eip_db` | 数据库名称 | +| `DB_PORT` | `5432` | 数据库端口 | +| `REDIS_PORT` | `6379` | Redis 端口 | +| `REDIS_PASSWORD` | *(空)* | Redis 密码(建议设置) | +| `MINIO_ACCESS_KEY` | `minio_admin` | MinIO 用户名 | +| `MINIO_API_PORT` | `9000` | MinIO API 端口 | +| `MINIO_CONSOLE_PORT` | `9001` | MinIO 控制台端口 | +| `API_PORT` | `8000` | API 服务端口 | +| `WEB_PORT` | `3000` | Web 前端端口 | +| `HTTP_PORT` | `80` | Nginx HTTP 端口 | +| `HTTPS_PORT` | `443` | Nginx HTTPS 端口 | +| `REGISTRY` | `ghcr.io` | 镜像仓库地址 | +| `IMAGE_TAG` | `latest` | 镜像标签(回滚时指定 SHA) | +| `CORS_ORIGINS` | `http://localhost:3000` | CORS 白名单(生产环境务必修改) | +| `OPENAI_API_KEY` | *(空)* | OpenAI API Key(可选,启用 AI 功能) | +| `OPENAI_MODEL` | `gpt-4o-mini` | OpenAI 模型 | + +### 5.3 生产环境 `.env` 最小配置模板 + +```bash +# ===== 必填 ===== +POSTGRES_PASSWORD=<强密码> +JWT_SECRET=<64位随机字符串> +MINIO_SECRET_KEY=<强密码> +IMAGE_OWNER= + +# ===== 强烈建议修改 ===== +REDIS_PASSWORD=<强密码> +CORS_ORIGINS=https://your-domain.com + +# ===== 可选 ===== +# OPENAI_API_KEY=sk-xxx +``` + +### 5.4 已修复的环境变量问题 + +| 问题 | 修复前 | 修复后 | +|------|--------|--------| +| Web 变量名不匹配 | `NEXT_PUBLIC_API_URL` | `NEXT_PUBLIC_API_BASE_URL` (与 `apps/web/src/lib/api.ts` 一致) | +| Redis 健康检查不带密码 | `redis-cli ping` | `redis-cli -a $REDIS_PASSWORD ping \| grep PONG` | +| API 缺少 CORS 配置 | 未设置 | 新增 `CORS_ORIGINS` 环境变量 | +| IMAGE_OWNER 有无效默认值 | `${IMAGE_OWNER:-your-org}` | `${IMAGE_OWNER}`(强制要求配置) | +| 镜像标签硬编码 | `:latest` | `${IMAGE_TAG:-latest}`(支持回滚指定版本) | + +--- + +## 6. 运维命令速查 + +```bash +# ---- 查看服务状态 ---- +docker compose -f docker-compose.prod.yml ps + +# ---- 查看日志 ---- +docker compose -f docker-compose.prod.yml logs -f api +docker compose -f docker-compose.prod.yml logs -f web + +# ---- 重启单个服务 ---- +docker compose -f docker-compose.prod.yml restart api + +# ---- 进入容器 ---- +docker exec -it oneerp_api sh + +# ---- 数据库备份 ---- +docker exec oneerp_postgres pg_dump -U eip_user eip_db > backup_$(date +%Y%m%d_%H%M%S).sql + +# ---- 数据库恢复 ---- +cat backup_20240101_120000.sql | docker exec -i oneerp_postgres psql -U eip_user eip_db + +# ---- 查看镜像历史 ---- +docker images | grep oneerp + +# ---- 清理无用镜像 ---- +docker image prune -f \ No newline at end of file From c8eba250a5ed2d9a46e742f868286375c842b77b Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Tue, 5 May 2026 14:55:50 +0800 Subject: [PATCH 03/54] feat(purchase/inventory): add PurchaseOrder, GoodsReceipt, StockPicking/Move models and enums - Add PurchaseOrderStatus, GoodsReceiptStatus, PickingType, PickingStatus, MoveStatus enums - Add PurchaseOrder, PurchaseOrderLine models (purchase management) - Add GoodsReceipt, GoodsReceiptLine models (goods receipt) - Add StockPicking, StockMove models (stock execution documents) - Add reservedQuantity to StockQuant for stock reservation - Add stockMoveId to InventoryTransaction for traceability chain - Update Company, Partner, Material, StockLocation with new relations - Register new models in COMPANY_SCOPED_MODELS for tenant isolation - Add new models to export-prisma-ddl-context.ts --- apps/api/prisma/schema.prisma | 493 +++++++++++++----- apps/api/scripts/export-prisma-ddl-context.ts | 6 + apps/api/src/prisma/prisma.service.ts | 6 + 3 files changed, 370 insertions(+), 135 deletions(-) diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index ffb4ea0..0eb88f0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -26,25 +26,28 @@ model Company { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - users UserCompanyRole[] - departments Department[] - orders Order[] - warehouses Warehouse[] - locations StockLocation[] - materials Material[] - partners Partner[] - products Product[] - categories ProductCategory[] - boms Bom[] - fileRecords FileRecord[] - workflows Workflow[] - customFields CustomFieldDefinition[] - auditLogs AuditLog[] - accounts Account[] - journals Journal[] - journalEntries JournalEntry[] + users UserCompanyRole[] + departments Department[] + orders Order[] + warehouses Warehouse[] + locations StockLocation[] + materials Material[] + partners Partner[] + products Product[] + categories ProductCategory[] + boms Bom[] + fileRecords FileRecord[] + workflows Workflow[] + customFields CustomFieldDefinition[] + auditLogs AuditLog[] + accounts Account[] + journals Journal[] + journalEntries JournalEntry[] eventDlqRecords EventDlq[] - taxCodes TaxCode[] + taxCodes TaxCode[] + purchaseOrders PurchaseOrder[] + goodsReceipts GoodsReceipt[] + stockPickings StockPicking[] } enum PartnerType { @@ -80,6 +83,51 @@ enum EntryPostingStatus { CANCELLED } +// ========================================== +// Purchase Module Enums +// ========================================== + +enum PurchaseOrderStatus { + DRAFT + SUBMITTED + APPROVED + ORDERED + PARTIALLY_RECEIVED + RECEIVED + CANCELLED +} + +enum GoodsReceiptStatus { + DRAFT + CONFIRMED + CANCELLED +} + +// ========================================== +// Stock Execution Enums +// ========================================== + +enum PickingType { + INBOUND + OUTBOUND + INTERNAL +} + +enum PickingStatus { + DRAFT + CONFIRMED + DONE + CANCELLED +} + +enum MoveStatus { + DRAFT + WAITING + CONFIRMED + DONE + CANCELLED +} + model User { id String @id @default(uuid()) email String @unique @@ -127,23 +175,25 @@ model Department { } model Partner { - id String @id @default(uuid()) - code String? - name String - type PartnerType @default(CUSTOMER) - contact String? - phone String? - email String? - taxId String? - address String? + id String @id @default(uuid()) + code String? + name String + type PartnerType @default(CUSTOMER) + contact String? + phone String? + email String? + taxId String? + address String? customAttributes Json? - isActive Boolean @default(true) - companyId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - company Company @relation(fields: [companyId], references: [id]) - orders Order[] + isActive Boolean @default(true) + companyId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + company Company @relation(fields: [companyId], references: [id]) + orders Order[] + purchaseOrders PurchaseOrder[] + goodsReceipts GoodsReceipt[] journalEntryLines JournalEntryLine[] @@unique([companyId, code]) @@ -163,11 +213,11 @@ model TaxCode { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - company Company @relation(fields: [companyId], references: [id]) - account Account? @relation(fields: [accountId], references: [id]) - orders Order[] + company Company @relation(fields: [companyId], references: [id]) + account Account? @relation(fields: [accountId], references: [id]) + orders Order[] orderItems OrderItem[] - invoices Invoice[] + invoices Invoice[] @@unique([companyId, code]) @@index([companyId, active]) @@ -179,20 +229,20 @@ model TaxCode { // ========================================== model Order { - id String @id @default(uuid()) - orderNo String @unique - partnerId String - salesId String // 挂单销售员 - companyId String - status String // "DRAFT", "PENDING", "IN_PRODUCTION", "SHIPPED", "COMPLETED" - totalAmount Float @default(0) - subTotal Float @default(0) - taxTotal Float @default(0) - taxCodeId String? - aiSummary Json? // 存放 AI 抓取的客户需求 JSON 结构 + id String @id @default(uuid()) + orderNo String @unique + partnerId String + salesId String // 挂单销售员 + companyId String + status String // "DRAFT", "PENDING", "IN_PRODUCTION", "SHIPPED", "COMPLETED" + totalAmount Float @default(0) + subTotal Float @default(0) + taxTotal Float @default(0) + taxCodeId String? + aiSummary Json? // 存放 AI 抓取的客户需求 JSON 结构 customAttributes Json? - expectedDate DateTime? // 预计交付日期 - notes String? // 备注 + expectedDate DateTime? // 预计交付日期 + notes String? // 备注 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -207,22 +257,79 @@ model Order { } model OrderItem { - id String @id @default(uuid()) - orderId String - productId String // 关联的产品/模型名称 - quantity Int - unitPrice Float - totalPrice Float - subTotal Float @default(0) - taxAmount Float @default(0) - taxRate Float @default(0) - taxCodeId String? + id String @id @default(uuid()) + orderId String + productId String // 关联的产品/模型名称 + quantity Int + unitPrice Float + totalPrice Float + subTotal Float @default(0) + taxAmount Float @default(0) + taxRate Float @default(0) + taxCodeId String? customAttributes Json? order Order @relation(fields: [orderId], references: [id]) taxCode TaxCode? @relation(fields: [taxCodeId], references: [id]) } +// ========================================== +// Purchase Management +// ========================================== + +model PurchaseOrder { + id String @id @default(uuid()) + orderNo String @unique + partnerId String + orderDate DateTime @default(now()) + expectedDate DateTime? + status PurchaseOrderStatus @default(DRAFT) + currency String @default("CNY") + subTotal Float @default(0) + taxTotal Float @default(0) + totalAmount Float @default(0) + notes String? + companyId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + partner Partner @relation(fields: [partnerId], references: [id]) + company Company @relation(fields: [companyId], references: [id]) + lines PurchaseOrderLine[] + goodsReceipts GoodsReceipt[] + + @@index([companyId, partnerId]) + @@index([companyId, status]) +} + +model PurchaseOrderLine { + id String @id @default(uuid()) + orderId String + lineNo Int + materialId String? + productId String? + description String? + quantity Float + receivedQuantity Float @default(0) + unitPrice Float + taxRate Float @default(0) + taxAmount Float @default(0) + subTotal Float @default(0) + totalAmount Float @default(0) + companyId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + order PurchaseOrder @relation(fields: [orderId], references: [id]) + material Material? @relation(fields: [materialId], references: [id]) + product Product? @relation(fields: [productId], references: [id]) + goodsReceiptLines GoodsReceiptLine[] + + @@unique([orderId, lineNo]) + @@index([materialId]) + @@index([productId]) +} + // ========================================== // 智能仓储与物料管理 // ========================================== @@ -233,8 +340,8 @@ model Warehouse { type String // "MATERIAL", "FINISHED", "PART" companyId String - company Company @relation(fields: [companyId], references: [id]) - locations StockLocation[] + company Company @relation(fields: [companyId], references: [id]) + locations StockLocation[] } model StockLocation { @@ -245,7 +352,7 @@ model StockLocation { isActive Boolean @default(true) companyId String warehouseId String? - parentId String? // 自引用父库位,支持 华南仓/货架区/A01层 层次结构 + parentId String? // 自引用父库位,支持 华南仓/货架区/A01层 层次结构 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -256,6 +363,8 @@ model StockLocation { quants StockQuant[] outgoingTransactions InventoryTransaction[] @relation("StockMoveSourceLocation") incomingTransactions InventoryTransaction[] @relation("StockMoveDestinationLocation") + sourceMoves StockMove[] @relation("StockMoveSourceLocation") + destMoves StockMove[] @relation("StockMoveDestLocation") @@unique([companyId, code]) @@index([companyId, warehouseId]) @@ -278,6 +387,9 @@ model Material { inventoryTransactions InventoryTransaction[] products Product[] bomLines BomLine[] + purchaseOrderLines PurchaseOrderLine[] + goodsReceiptLines GoodsReceiptLine[] + stockMoves StockMove[] } model ProductCategory { @@ -299,24 +411,26 @@ model ProductCategory { } model Product { - id String @id @default(uuid()) - sku String - name String - type ProductType @default(STOCKABLE) - categoryId String? - materialId String? - uom String @default("pcs") - description String? + id String @id @default(uuid()) + sku String + name String + type ProductType @default(STOCKABLE) + categoryId String? + materialId String? + uom String @default("pcs") + description String? customAttributes Json? - isActive Boolean @default(true) - companyId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - company Company @relation(fields: [companyId], references: [id]) - category ProductCategory? @relation(fields: [categoryId], references: [id]) - material Material? @relation(fields: [materialId], references: [id]) - boms Bom[] + isActive Boolean @default(true) + companyId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + company Company @relation(fields: [companyId], references: [id]) + category ProductCategory? @relation(fields: [categoryId], references: [id]) + material Material? @relation(fields: [materialId], references: [id]) + boms Bom[] + PurchaseOrderLine PurchaseOrderLine[] + GoodsReceiptLine GoodsReceiptLine[] @@unique([companyId, sku]) @@index([companyId, categoryId]) @@ -341,19 +455,19 @@ model Bom { } model CustomFieldDefinition { - id String @id @default(uuid()) - modelName String - fieldName String - label String - type CustomFieldType - required Boolean @default(false) - referenceModel String? - referenceLabelField String? - referenceValueField String? + id String @id @default(uuid()) + modelName String + fieldName String + label String + type CustomFieldType + required Boolean @default(false) + referenceModel String? + referenceLabelField String? + referenceValueField String? referenceRelationField String? - companyId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + companyId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt company Company @relation(fields: [companyId], references: [id]) @@ -377,11 +491,12 @@ model BomLine { } model StockQuant { - id String @id @default(uuid()) - locationId String - materialId String - batchNo String - quantity Float + id String @id @default(uuid()) + locationId String + materialId String + batchNo String + quantity Float + reservedQuantity Float @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -393,6 +508,111 @@ model StockQuant { @@index([materialId]) } +// ========================================== +// Goods Receipt +// ========================================== + +model GoodsReceipt { + id String @id @default(uuid()) + receiptNo String @unique + purchaseOrderId String? + partnerId String? + receiptDate DateTime @default(now()) + status GoodsReceiptStatus @default(DRAFT) + notes String? + companyId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id]) + partner Partner? @relation(fields: [partnerId], references: [id]) + company Company @relation(fields: [companyId], references: [id]) + lines GoodsReceiptLine[] + + @@index([companyId, purchaseOrderId]) + @@index([companyId, status]) +} + +model GoodsReceiptLine { + id String @id @default(uuid()) + receiptId String + lineNo Int + purchaseOrderLineId String? + materialId String? + productId String? + quantity Float + batchNo String? + destLocationId String? + companyId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + receipt GoodsReceipt @relation(fields: [receiptId], references: [id]) + purchaseOrderLine PurchaseOrderLine? @relation(fields: [purchaseOrderLineId], references: [id]) + material Material? @relation(fields: [materialId], references: [id]) + product Product? @relation(fields: [productId], references: [id]) + + @@unique([receiptId, lineNo]) + @@index([purchaseOrderLineId]) + @@index([materialId]) +} + +// ========================================== +// Stock Picking & Move +// ========================================== + +model StockPicking { + id String @id @default(uuid()) + pickingNo String @unique + type PickingType + referenceType String? + referenceId String? + scheduledDate DateTime? + completedDate DateTime? + status PickingStatus @default(DRAFT) + notes String? + companyId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + company Company @relation(fields: [companyId], references: [id]) + moves StockMove[] + + @@index([companyId, status]) + @@index([companyId, type]) + @@index([referenceType, referenceId]) +} + +model StockMove { + id String @id @default(uuid()) + pickingId String + lineNo Int + materialId String + sourceLocationId String? + destLocationId String? + quantity Float + quantityDone Float @default(0) + batchNo String? + unitCost Float @default(0) + totalCost Float @default(0) + status MoveStatus @default(DRAFT) + companyId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + picking StockPicking @relation(fields: [pickingId], references: [id]) + material Material @relation(fields: [materialId], references: [id]) + sourceLocation StockLocation? @relation("StockMoveSourceLocation", fields: [sourceLocationId], references: [id]) + destLocation StockLocation? @relation("StockMoveDestLocation", fields: [destLocationId], references: [id]) + transactions InventoryTransaction[] + + @@unique([pickingId, lineNo]) + @@index([materialId]) + @@index([sourceLocationId]) + @@index([destLocationId]) + @@index([companyId, status]) +} + // ========================================== // 附加模块: 文件、流转、制造与财务 // ========================================== @@ -412,26 +632,29 @@ model FileRecord { } model InventoryTransaction { - id String @id @default(uuid()) - type String // "INBOUND", "OUTBOUND", "TRANSFER" - materialId String - sourceLocationId String? - destLocationId String? - quantity Float - batchNo String? - operatorId String - companyId String - referenceNo String? // 关联单号(订单号/采购号等) - note String? - createdAt DateTime @default(now()) - - material Material @relation(fields: [materialId], references: [id]) - sourceLocation StockLocation? @relation("StockMoveSourceLocation", fields: [sourceLocationId], references: [id]) - destLocation StockLocation? @relation("StockMoveDestinationLocation", fields: [destLocationId], references: [id]) + id String @id @default(uuid()) + type String // "INBOUND", "OUTBOUND", "TRANSFER" + materialId String + sourceLocationId String? + destLocationId String? + quantity Float + batchNo String? + operatorId String + companyId String + referenceNo String? // 关联单号(订单号/采购号等) + stockMoveId String? // 关联库存执行单据行 + note String? + createdAt DateTime @default(now()) + + material Material @relation(fields: [materialId], references: [id]) + sourceLocation StockLocation? @relation("StockMoveSourceLocation", fields: [sourceLocationId], references: [id]) + destLocation StockLocation? @relation("StockMoveDestinationLocation", fields: [destLocationId], references: [id]) + stockMove StockMove? @relation(fields: [stockMoveId], references: [id]) @@index([companyId, materialId]) @@index([sourceLocationId]) @@index([destLocationId]) + @@index([stockMoveId]) } model Workflow { @@ -444,7 +667,7 @@ model Workflow { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - company Company? @relation(fields: [companyId], references: [id]) + company Company? @relation(fields: [companyId], references: [id]) states WorkflowState[] transitions WorkflowTransition[] @@ -518,18 +741,18 @@ model WorkReport { } model Invoice { - id String @id @default(uuid()) - invoiceNo String @unique - orderId String - amount Float - subTotal Float @default(0) - taxAmount Float @default(0) - taxCodeId String? - status String // "UNPAID", "PARTIAL", "PAID" + id String @id @default(uuid()) + invoiceNo String @unique + orderId String + amount Float + subTotal Float @default(0) + taxAmount Float @default(0) + taxCodeId String? + status String // "UNPAID", "PARTIAL", "PAID" postingStatus EntryPostingStatus @default(DRAFT) - companyId String - dueDate DateTime? - issuedDate DateTime @default(now()) + companyId String + dueDate DateTime? + issuedDate DateTime @default(now()) order Order @relation(fields: [orderId], references: [id]) taxCode TaxCode? @relation(fields: [taxCodeId], references: [id]) @@ -557,9 +780,9 @@ model Account { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - parent Account? @relation("AccountTree", fields: [parentId], references: [id]) - children Account[] @relation("AccountTree") - company Company @relation(fields: [companyId], references: [id]) + parent Account? @relation("AccountTree", fields: [parentId], references: [id]) + children Account[] @relation("AccountTree") + company Company @relation(fields: [companyId], references: [id]) lines JournalEntryLine[] taxCodes TaxCode[] @@ -628,18 +851,18 @@ model JournalEntryLine { } model EventDlq { - id String @id @default(uuid()) + id String @id @default(uuid()) eventName String idempotencyKey String? payload Json error String - attempts Int @default(1) - maxAttempts Int @default(5) + attempts Int @default(1) + maxAttempts Int @default(5) nextRetryAt DateTime? - status String @default("PENDING") + status String @default("PENDING") companyId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt company Company? @relation(fields: [companyId], references: [id]) diff --git a/apps/api/scripts/export-prisma-ddl-context.ts b/apps/api/scripts/export-prisma-ddl-context.ts index 03a0051..d9580c7 100644 --- a/apps/api/scripts/export-prisma-ddl-context.ts +++ b/apps/api/scripts/export-prisma-ddl-context.ts @@ -39,6 +39,12 @@ function main() { 'JournalEntry', 'JournalEntryLine', 'Account', + 'PurchaseOrder', + 'PurchaseOrderLine', + 'GoodsReceipt', + 'GoodsReceiptLine', + 'StockPicking', + 'StockMove', ].includes(model.name), ); diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts index f766667..aec4f92 100644 --- a/apps/api/src/prisma/prisma.service.ts +++ b/apps/api/src/prisma/prisma.service.ts @@ -42,6 +42,12 @@ const COMPANY_SCOPED_MODELS = new Set([ 'EventDlq', 'AuditLog', 'Department', + 'PurchaseOrder', + 'PurchaseOrderLine', + 'GoodsReceipt', + 'GoodsReceiptLine', + 'StockPicking', + 'StockMove', ]); @Injectable() From e62e6aee5033a439019de9f023a1eb643fec41c4 Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Tue, 5 May 2026 14:58:31 +0800 Subject: [PATCH 04/54] docs: update outdated documentation to match code facts - README.md: mark TaxCode engine as completed in Roadmap, update AI section, add ARCHITECTURE.md link - docs/architecture/ARCHITECTURE.md: create comprehensive architecture overview (new file) - docs/analysis/MISSING_FEATURES_ANALYSIS.md: fix status table (TaxCode 5%->60%, AI 20%->65%), correct CI/CD status, update AI implementation details - docs/plans/PROJECT_PLAN_AND_STATUS.md: update Phase 3 AI status to 'core logic landed', update date to 2026-05-05 - .github/copilot-instructions.md: add ARCHITECTURE.md to required reading list --- .github/copilot-instructions.md | 1 + README.md | 5 +- docs/analysis/MISSING_FEATURES_ANALYSIS.md | 1211 ++++++++++---------- docs/architecture/ARCHITECTURE.md | 293 +++++ docs/plans/PROJECT_PLAN_AND_STATUS.md | 15 +- 5 files changed, 904 insertions(+), 621 deletions(-) create mode 100644 docs/architecture/ARCHITECTURE.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ceab15e..8e04d3c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -16,6 +16,7 @@ ## 必读文档 +- `docs/architecture/ARCHITECTURE.md` - `docs/architecture/STANDARDS.md` - `docs/plans/PROJECT_PLAN_AND_STATUS.md` - `docs/plans/EXECUTION_PLAN.md` diff --git a/README.md b/README.md index e9a090d..3ed606d 100644 --- a/README.md +++ b/README.md @@ -131,11 +131,11 @@ flowchart LR - AI Command Bar:自然语言触发业务动作(支持 dry-run 草稿确认) - Chat2Dash:自然语言转图表洞察 - Chat2SQL:自然语言转查询语句(只读场景) -- OCR Draft(规划中):附件识别并生成草稿单据 +- Document Draft:附件文件名解析生成发票草稿(LLM 降级为规则兜底) ## Roadmap -- [ ] P0: 税务引擎(税码、税率、含税/未税) +- [x] P0: 税务引擎(税码、税率、含税/未税)—— `TaxCode` 模型 + `finance.service.resolveTaxCode()` 已落地 - [ ] P0: 采购全链路(询价、采购单、收货、应付) - [ ] P1: 库存单据头(Stock Picking/Wave) - [ ] P1: 多币种与汇率重估 @@ -155,6 +155,7 @@ flowchart LR ## 文档入口 +- [docs/architecture/ARCHITECTURE.md](./docs/architecture/ARCHITECTURE.md) - [docs/architecture/STANDARDS.md](./docs/architecture/STANDARDS.md) - [docs/plans/PROJECT_PLAN_AND_STATUS.md](./docs/plans/PROJECT_PLAN_AND_STATUS.md) - [docs/plans/PROJECT_PLAN.md](./docs/plans/PROJECT_PLAN.md) diff --git a/docs/analysis/MISSING_FEATURES_ANALYSIS.md b/docs/analysis/MISSING_FEATURES_ANALYSIS.md index 99dff22..efbb95d 100644 --- a/docs/analysis/MISSING_FEATURES_ANALYSIS.md +++ b/docs/analysis/MISSING_FEATURES_ANALYSIS.md @@ -1,609 +1,602 @@ -# OneERP 缺失功能分析报告 - -> **生成日期**: 2026-03-26 -> **分析范围**: 后端 API、前端 Web、数据库模型、核心引擎 -> **对比基准**: `docs/plans/` 中的规划文档 - ---- - -## 执行摘要 - -OneERP 项目已经完成了**扎实的技术基础架构**(元数据驱动、通用 CRUD、事件总线、多租户),但在**业务模块完整性**和**企业级特性**方面存在显著缺口。 - -### 当前完成度评估 - -| 模块分类 | 完成度 | 状态 | -|---------|-------|------| -| 核心引擎(CRUD/Metadata/Event) | 90% | ✅ 优秀 | -| 销售订单模块 | 70% | ⚠️ 基础完成 | -| 库存管理 | 60% | ⚠️ 缺少关键保护 | -| **采购模块** | **0%** | ❌ **完全缺失** | -| 生产管理 | 50% | ⚠️ 仅有工单 | -| 财务会计 | 40% | ⚠️ 记账存在但不完整 | -| **税务引擎** | **5%** | ❌ **硬编码 13%** | -| AI 功能 | 20% | ⚠️ 仅 UI,逻辑未接入 | -| 报表系统 | 10% | ❌ 缺少财务三表 | - ---- - -## 一、高优先级缺失功能 (P0 - 阻塞性) - -### 🔴 1. 采购管理全链路(Purchase Management) - -**问题严重性**: ⚠️ **业务闭环受阻** - 无法完成"采购→收货→应付→付款"完整链路 - -#### 缺失组件清单 - -##### 数据库层 -```prisma -// ❌ 以下模型完全不存在于 schema.prisma - -model PurchaseOrder { - // 采购订单主表 -} - -model PurchaseOrderLine { - // 采购订单明细 -} - -model GoodsReceipt { - // 收货单(与采购单关联) -} - -model PurchaseRequisition { - // 采购申请单(可选但推荐) -} -``` - -##### 后端服务层 -- ❌ 无 `PurchaseOrdersModule` -- ❌ 无供应商询价接口 -- ❌ 无三单匹配逻辑(PO ↔ GR ↔ Invoice) -- ❌ 无收货入库自动触发库存增加的事件监听器 - -##### 前端页面 -- ❌ 无采购订单列表页 -- ❌ 无采购单创建/编辑表单 -- ❌ 无收货单执行界面 - -#### 影响范围 -- 📦 **供应链断裂**: 无法管理原材料采购 -- 💰 **应付账款无源头**: 现有 Invoice 无法关联采购订单 -- 📊 **成本核算缺失**: 库存成本无法从采购价推导 - -#### 推荐行动 -**优先级**: P0 - 立即开发 -**预计工期**: 2 周 -**负责人**: Backend + Frontend 协同 - ---- - -### 🔴 2. 可配置税务引擎(Configurable Tax Engine) - -**问题严重性**: ⚠️ **合规风险** - 硬编码税率无法适配多地区 - -#### 当前问题 - -在 `apps/api/src/finance/finance.service.ts:151-154`: - -```typescript -// ❌ 硬编码 13% 增值税 -const taxAmount = new Decimal(totalAmount).mul(0.13); -const totalWithTax = new Decimal(totalAmount).add(taxAmount); -``` - -#### 缺失组件 - -##### 数据库层 -```prisma -// ❌ 不存在 - -model TaxCode { - id String @id @default(cuid()) - code String @unique // "VAT_13", "GST_5" - name String - rate Decimal @db.Decimal(5, 4) // 0.1300 - type TaxType // SALES, PURCHASE, WITHHOLDING - region String? // "CN", "US_CA" - active Boolean @default(true) -} - -enum TaxType { - SALES - PURCHASE - WITHHOLDING - NONE -} -``` - -##### 后端逻辑 -- ❌ 无税码主数据管理接口 -- ❌ 无自动税率查找(基于客户地区、商品类别) -- ❌ 无反算含税价功能 -- ❌ 无税务豁免处理 - -##### 前端配置 -- ❌ 无税码配置管理界面 -- ❌ 订单表单无法选择税率 - -#### 影响范围 -- 🌍 **无法多地区运营**: 不同国家税率不同 -- ⚖️ **税务合规风险**: 固定 13% 不符合实际业务 -- 💸 **含税/未税混乱**: 缺少统一的税额计算标准 - -#### 推荐行动 -**优先级**: P0 - 必须修复 -**预计工期**: 1 周 -**依赖**: 需同步改造 Order、Invoice、Finance 模块 - ---- - -### 🔴 3. 库存单据头与出库流程(Stock Picking & Fulfillment) - -**问题严重性**: ⚠️ **物流执行缺失** - 有订单但无法正确发货 - -#### 缺失组件 - -##### 数据库层 -```prisma -// ❌ 以下模型不存在 - -model StockPicking { - // 出入库单据头(发货单/收货单) - id String @id - type PickingType // OUTBOUND, INBOUND, INTERNAL - orderId String? // 关联销售/采购订单 - status PickingStatus - moves StockMove[] -} - -model StockMove { - // 库存移动明细(从 A 库位 -> B 库位) - id String @id - pickingId String - productId String - fromLocationId String - toLocationId String - quantity Decimal - quantityDone Decimal // 实际完成数量 - status MoveStatus -} - -enum PickingType { - OUTBOUND // 销售出库 - INBOUND // 采购入库 - INTERNAL // 内部调拨 -} -``` - -##### 后端逻辑 -- ❌ 无发货单生成接口 -- ❌ 无拣货单打印 -- ❌ 无批次号扫码绑定 -- ❌ 无波次管理(Wave Management) - -##### 前端界面 -- ❌ 无发货执行工作台 -- ❌ 无扫码枪集成(条码监听器) - -#### 当前变通方案问题 - -现在的代码在 `orders.service.ts` 直接修改 `StockQuant.quantity`: - -```typescript -// ⚠️ 简化处理,缺少单据留痕 -await this.prisma.stockQuant.update({ - where: { id: quant.id }, - data: { quantity: { decrement: lineItem.quantity } } -}); -``` - -**问题**: -- 📝 无出库单据可追溯 -- 🚫 无法支持"分批发货" -- ⚠️ 无实物盘点对账依据 - -#### 推荐行动 -**优先级**: P0 - 核心流程 -**预计工期**: 2 周 -**依赖**: 需配合 Order 发货逻辑重构 - ---- - -### 🔴 4. 库存安全防护机制(Inventory Safety Guards) - -**问题严重性**: ⚠️ **数据一致性风险** - 并发场景可能超卖或负库存 - -#### 当前存在的隐患 - -##### 问题 1: 缺少库存预留(Stock Reservation) - -在 `orders.service.ts:155-169` 的扣减库存逻辑: - -```typescript -// ⚠️ 问题:订单创建时未预留库存 -// 如果订单确认到发货之间时间较长,其他订单可能抢占库存 -const quant = await this.prisma.stockQuant.findFirst({ - where: { - productId: lineItem.productId, - quantity: { gte: lineItem.quantity } - } -}); - -if (!quant) { - throw new ConflictException('库存不足'); -} -``` - -**正确做法应该是**: -```typescript -// ✅ 订单确认时预留,发货时扣减 -model StockQuant { - quantity Decimal // 实物数量 - reservedQuantity Decimal // 已预留数量 - availableQuantity Decimal // 可用 = 实物 - 预留 -} -``` - -##### 问题 2: 缺少并发保护(Concurrency Control) - -当前代码: -```typescript -// ❌ 先查后改,存在 Race Condition -const quant = await findFirst(...); -await update({ where: { id: quant.id }, data: { quantity: newQty } }); -``` - -高并发场景下,两个请求可能同时读到 `quantity=10`,都认为足够,结果扣成负数。 - -**正确做法**: -```typescript -// ✅ 使用 WHERE 条件保证原子性 -const result = await this.prisma.stockQuant.updateMany({ - where: { - id: quantId, - quantity: { gte: decrementAmount } // 乐观锁断言 - }, - data: { quantity: { decrement: decrementAmount } } -}); - -if (result.count === 0) { - throw new ConflictException('库存不足或已被其他订单占用'); -} -``` - -##### 问题 3: 缺少成本计价方法 - -- ❌ 无 FIFO(先进先出) -- ❌ 无 LIFO(后进先出) -- ❌ 无移动加权平均 -- ⚠️ 当前 `StockQuant` 无 `costPrice` 字段 - -#### 推荐行动 -**优先级**: P0 - 数据安全 -**预计工期**: 1 周 -**方案**: -1. 添加 `reservedQuantity` 字段 -2. 改造所有库存变动为原子操作 -3. 引入成本核算层 - ---- - -## 二、中优先级缺失功能 (P1 - 重要但非阻塞) - -### 🟡 1. 多币种支持(Multi-Currency) - -#### 缺失内容 -- ❌ 无 `Currency` 模型 -- ❌ 无 `ExchangeRate` 汇率表 -- ❌ 订单、发票无 `currencyId` 字段 -- ❌ 无汇兑损益自动记账 - -#### 影响 -- 🌏 无法支持跨境贸易 -- 💱 外币交易无法记录 - -#### 推荐行动 -**优先级**: P1 -**预计工期**: 1 周 - ---- - -### 🟡 2. 高级数据网格(Advanced Data Grid) - -#### 规划已完成但未实现 - -在 `docs/plans/EXECUTION_PLAN.md` 明确提到: - -> 引入 AG Grid 或 TanStack Table v8,支持: -> - 行内编辑 (Inline Cell Editing) -> - 虚拟滚动 (Virtual Scrolling) -> - 用户自定义列 - -#### 当前状态 -- ✅ TanStack Table 已引入 -- ❌ **无行内编辑功能** -- ❌ 无虚拟滚动(大数据集会卡顿) -- ❌ 无列偏好持久化 -- ❌ 无吸顶操作栏(Sticky Action Bar) - -#### 影响 -- 📊 数据密集型页面(如订单明细录入)体验差 -- 🖱️ 每次修改都要弹窗,效率低 - -#### 推荐行动 -**优先级**: P1 - 用户体验 -**预计工期**: 1 周 - ---- - -### 🟡 3. PostgreSQL 行级安全(RLS - Row Level Security) - -#### 当前安全隐患 - -虽然已有 `TenantContextMiddleware`,但仅在应用层过滤: - -```typescript -// apps/api/src/core/tenant/tenant-context.middleware.ts -// ⚠️ 应用层控制,不是物理隔离 -req.tenantContext = { companyId: user.companyId }; -``` - -**风险**: -- 如果开发者忘记在 Prisma 查询中加 `where: { companyId }`,会导致数据越权 -- 原始 SQL 查询可能绕过中间件 - -#### 推荐方案 - -在 PostgreSQL 启用 RLS: - -```sql --- 为每张表启用 RLS -ALTER TABLE "Order" ENABLE ROW LEVEL SECURITY; - --- 创建策略 -CREATE POLICY tenant_isolation ON "Order" - USING (company_id = current_setting('app.current_tenant')::text); -``` - -配合 Prisma Middleware 自动注入租户上下文: - -```typescript -prisma.$use(async (params, next) => { - await prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`; - return next(params); -}); -``` - -#### 推荐行动 -**优先级**: P1 - 安全加固 -**预计工期**: 3 天 - ---- - -## 三、部分实现功能的缺口 - -### ⚠️ 1. 工作流引擎(Workflow Engine) - -#### 已实现 -- ✅ 基础状态跃迁 (`DRAFT -> CONFIRMED -> SHIPPED`) -- ✅ 事件触发器 (`workflow.action.sale_order.shipped`) - -#### 缺失 -- ❌ 无动态流程定义(目前写死在代码里) -- ❌ 无条件分支(例如:金额 > 10万需要总监审批) -- ❌ 无审批链路可视化 -- ❌ 无回退/驳回功能 -- ❌ 审计日志不完整(缺少操作人、时间戳详情) - -#### 建议 -引入 `WorkflowDefinition` 模型,支持低代码配置工作流。 - ---- - -### ⚠️ 2. 财务模块(Finance Module) - -#### 已实现 -- ✅ 科目表 (`ChartOfAccount`) -- ✅ 会计分录 (`JournalEntry` + `JournalEntryLine`) -- ✅ 借贷平衡校验 - -#### 缺失 -- ❌ 无试算平衡表(Trial Balance) -- ❌ 无期末结账(Period Closing) -- ❌ 无损益表(P&L Statement) -- ❌ 无资产负债表(Balance Sheet) -- ❌ 无现金流量表(Cash Flow Statement) -- ❌ 调整分录(Adjustment Entry)功能不完整 - -#### 建议 -先完成报表生成器,再考虑自动化结账流程。 - ---- - -### ⚠️ 3. AI 功能集成(AI Features) - -#### 已实现(仅 UI) -- ✅ Command Palette 界面 (`apps/web/src/components/ai/CommandPalette.tsx`) -- ✅ Chat2DashPanel 组件 -- ✅ DocumentDraftUploader(OCR 上传器) - -#### 缺失(核心逻辑) -- ❌ **LLM 接入未完成** - `LlmAdapterService` 仅有空架子 -- ❌ **Function Calling 未实现** - 无意图识别和参数提取 -- ❌ **Chat2SQL 逻辑缺失** - 无 Schema 喂给模型,无 SQL 生成 -- ❌ **OCR 识别服务未接入** - 上传后无处理逻辑 - -#### 代码证据 - -`apps/api/src/ai/llm-adapter.service.ts:15-20`: - -```typescript -// ❌ 空实现 -async completion(prompt: string, options?: any): Promise { - // TODO: Integrate with actual LLM provider (OpenAI, Azure, etc.) - return 'Mock LLM response'; -} -``` - -#### 推荐行动 -**优先级**: P1 -**预计工期**: 2 周 -**依赖**: 需选型 LLM 提供商(OpenAI / Azure / 自部署) - ---- - -## 四、完全缺失的功能模块 - -### ❌ 1. CRM 客户关系管理(P2) - -**规划文档**: `README.md:142` 提到 "P2: CRM 线索与商机漏斗" - -**缺失内容**: -- 线索(Lead) -- 商机(Opportunity) -- 销售漏斗(Funnel) -- 活动记录(Activity) -- 客户分级(Customer Segmentation) - ---- - -### ❌ 2. HR 与薪资(P2) - -**规划文档**: `README.md:143` 提到 "P2: HR/Payroll" - -**缺失内容**: -- 员工档案(Employee) -- 考勤打卡(Attendance) -- 请假申请(Leave) -- 薪资计算引擎(Payroll) - ---- - -### ❌ 3. 报表中心(Critical) - -**影响**: 📊 **无法进行经营分析** - -**缺失报表**: -- 财务三大表(损益表、资产负债表、现金流量表) -- 库存周转率分析 -- 销售业绩仪表盘 -- 利润中心分析 -- 供应商绩效分析 - -**当前状态**: -- 仅有 Dashboard 页面的简单统计卡片 -- 无可交互的图表组件(虽有 `recharts` 依赖但未使用) - ---- - -### ❌ 4. DevOps 与测试 - -#### 缺失的 CI/CD -- ❌ 无 `.github/workflows/` 配置 -- ❌ 无自动化测试运行 -- ❌ 无自动化部署脚本 - -#### 测试覆盖率未知 -- 虽有 `.spec.ts` 文件,但不确定是否可运行 -- 无集成测试 -- 无端到端测试(E2E) - -#### 监控与日志 -- ❌ 无日志聚合(ELK/Loki) -- ❌ 无性能监控(APM) -- ❌ 无告警系统 - ---- - -## 五、架构优势与不足总结 - -### ✅ 架构亮点 - -1. **元数据驱动** - 极大减少重复代码 -2. **通用 CRUD API** - 新增模型几乎零成本 -3. **事件驱动** - 模块解耦良好 -4. **多租户设计** - 从第一天就考虑了 SaaS 架构 - -### ⚠️ 架构短板 - -1. **业务完整性不足** - 核心流程有断点(尤其采购) -2. **企业级特性缺失** - 税务、多币种、审批流 -3. **数据安全防护弱** - 并发控制、RLS 不到位 -4. **AI 承诺未兑现** - UI 做了,后端逻辑空白 -5. **可观测性缺失** - 无监控、无报表、无审计追溯 - ---- - -## 六、推荐实施路线图 - -### 第 1-2 周:P0 功能补齐(业务闭环) -- [ ] 实现 PurchaseOrder 模块(采购订单 + 收货单) -- [ ] 构建可配置税务引擎(TaxCode 主数据 + 动态税率计算) -- [ ] 改造库存扣减为原子操作(防并发超卖) - -### 第 3-4 周:P0 功能深化(流程完善) -- [ ] 实现 StockPicking/StockMove(出入库单据) -- [ ] 添加库存预留机制(`reservedQuantity`) -- [ ] 完成三单匹配逻辑(PO ↔ GR ↔ Invoice) - -### 第 5-6 周:P1 功能提升(用户体验) -- [ ] 高级数据网格(行内编辑、虚拟滚动) -- [ ] 多币种支持(Currency + ExchangeRate) -- [ ] PostgreSQL RLS 安全加固 - -### 第 7-8 周:AI 功能落地 -- [ ] 接入 LLM 提供商(OpenAI / Azure) -- [ ] 实现 Function Calling(NL2Action) -- [ ] 完成 Chat2SQL(Schema 注入 + SQL 生成) - -### 第 9-10 周:报表与分析 -- [ ] 财务三大报表生成器 -- [ ] 销售与库存分析仪表盘 -- [ ] 可交互式数据探索 - -### 第 11-12 周:测试与部署 -- [ ] 编写集成测试(采购→入库→应付→付款 E2E) -- [ ] 搭建 CI/CD 管道 -- [ ] 容器化与生产环境部署 - ---- - -## 七、关键文件路径索引 - -### 后端核心 -- 数据库模型: `/home/runner/work/OneERP/OneERP/apps/api/prisma/schema.prisma` -- 通用 CRUD: `/home/runner/work/OneERP/OneERP/apps/api/src/core/crud/` -- 工作流引擎: `/home/runner/work/OneERP/OneERP/apps/api/src/core/workflow/` -- 订单服务: `/home/runner/work/OneERP/OneERP/apps/api/src/orders/` -- 财务服务: `/home/runner/work/OneERP/OneERP/apps/api/src/finance/` - -### 前端核心 -- 动态引擎: `/home/runner/work/OneERP/OneERP/apps/web/src/components/dynamic/` -- AI 组件: `/home/runner/work/OneERP/OneERP/apps/web/src/components/ai/` -- 页面路由: `/home/runner/work/OneERP/OneERP/apps/web/src/app/` - -### 规划文档 -- 项目计划: `/home/runner/work/OneERP/OneERP/docs/plans/PROJECT_PLAN_AND_STATUS.md` -- 执行计划: `/home/runner/work/OneERP/OneERP/docs/plans/EXECUTION_PLAN.md` -- 核心模块开发计划: `/home/runner/work/OneERP/OneERP/docs/plans/CORE_MODULES_DEV_PLAN.md` - ---- - -## 八、结论 - -OneERP 项目的**技术架构设计优秀**,元数据驱动和事件驱动的理念非常先进,但**业务模块实现进度约 50%**。 - -**最紧急的任务是补齐采购模块和税务引擎**,这两个是企业 ERP 的核心刚需,否则系统无法投入实际使用。 - -其次是**库存安全加固**和**Stock Picking 流程**,防止数据不一致和业务断链。 - -AI 功能虽然 UI 做得很漂亮,但后端逻辑基本是空的,需要尽快对接真实的 LLM 服务。 - -报表系统完全缺失,建议在完成核心业务流后优先开发财务三表和销售分析看板。 - ---- - -**生成工具**: Claude Code Agent -**分析基准**: 完整代码库扫描 + 规划文档对比 -**建议审阅人**: 后端架构师、业务分析师、项目经理 +# OneERP 缺失功能分析报告 + +> **生成日期**: 2026-03-26 +> **分析范围**: 后端 API、前端 Web、数据库模型、核心引擎 +> **对比基准**: `docs/plans/` 中的规划文档 + +--- + +## 执行摘要 + +OneERP 项目已经完成了**扎实的技术基础架构**(元数据驱动、通用 CRUD、事件总线、多租户),但在**业务模块完整性**和**企业级特性**方面存在显著缺口。 + +### 当前完成度评估 + +| 模块分类 | 完成度 | 状态 | +|---------|-------|------| +| 核心引擎(CRUD/Metadata/Event) | 90% | ✅ 优秀 | +| 销售订单模块 | 70% | ⚠️ 基础完成 | +| 库存管理 | 60% | ⚠️ 缺少关键保护 | +| **采购模块** | **0%** | ❌ **完全缺失** | +| 生产管理 | 50% | ⚠️ 仅有工单 | +| 财务会计 | 40% | ⚠️ 记账存在但不完整 | +| **税务引擎** | **60%** | ✅ **TaxCode 模型 + 动态税码解析已落地** | +| AI 功能 | 65% | ✅ LLM Function Calling + Chat2SQL + 规则引擎兜底已落地 | +| 报表系统 | 10% | ❌ 缺少财务三表 | + +--- + +## 一、高优先级缺失功能 (P0 - 阻塞性) + +### 🔴 1. 采购管理全链路(Purchase Management) + +**问题严重性**: ⚠️ **业务闭环受阻** - 无法完成"采购→收货→应付→付款"完整链路 + +#### 组件状态(已更新 2026-05-05)清单 + +##### 数据库层 +```prisma +// ❌ 以下模型完全不存在于 schema.prisma + +model PurchaseOrder { + // 采购订单主表 +} + +model PurchaseOrderLine { + // 采购订单明细 +} + +model GoodsReceipt { + // 收货单(与采购单关联) +} + +model PurchaseRequisition { + // 采购申请单(可选但推荐) +} +``` + +##### 后端服务层 +- ❌ 无 `PurchaseOrdersModule` +- ❌ 无供应商询价接口 +- ❌ 无三单匹配逻辑(PO ↔ GR ↔ Invoice) +- ❌ 无收货入库自动触发库存增加的事件监听器 + +##### 前端页面 +- ❌ 无采购订单列表页 +- ❌ 无采购单创建/编辑表单 +- ❌ 无收货单执行界面 + +#### 影响范围 +- 📦 **供应链断裂**: 无法管理原材料采购 +- 💰 **应付账款无源头**: 现有 Invoice 无法关联采购订单 +- 📊 **成本核算缺失**: 库存成本无法从采购价推导 + +#### 推荐行动 +**优先级**: P0 - 立即开发 +**预计工期**: 2 周 +**负责人**: Backend + Frontend 协同 + +--- + +### 🔴 2. 可配置税务引擎(Configurable Tax Engine) + +**问题严重性**: ⚠️ ~~合规风险~~ → **已修复** — TaxCode 主数据模型与动态税率解析已落地 + +#### 当前问题 → 已修复 + +**已修复 (2026-05-05)**: inance.service.ts 现在通过 esolveTaxCode() 从数据库动态查找税码,未指定时回退到默认税码或 13% 兜底。 +#### 组件状态(已更新 2026-05-05) + +##### 数据库层 +```prisma +// ❌ 不存在 + +model TaxCode { + id String @id @default(cuid()) + code String @unique // "VAT_13", "GST_5" + name String + rate Decimal @db.Decimal(5, 4) // 0.1300 + type TaxType // SALES, PURCHASE, WITHHOLDING + region String? // "CN", "US_CA" + active Boolean @default(true) +} + +enum TaxType { + SALES + PURCHASE + WITHHOLDING + NONE +} +``` + +##### 后端逻辑 +- ❌ 无税码主数据管理接口 +- ❌ 无自动税率查找(基于客户地区、商品类别) +- ❌ 无反算含税价功能 +- ❌ 无税务豁免处理 + +##### 前端配置 +- ❌ 无税码配置管理界面 +- ❌ 订单表单无法选择税率 + +#### 影响范围 +- 🌍 **无法多地区运营**: 不同国家税率不同 +- ⚖️ **税务合规风险**: 固定 13% 不符合实际业务 +- 💸 **含税/未税混乱**: 缺少统一的税额计算标准 + +#### 推荐行动 +**优先级**: P0 - 必须修复 +**预计工期**: 1 周 +**依赖**: 需同步改造 Order、Invoice、Finance 模块 + +--- + +### 🔴 3. 库存单据头与出库流程(Stock Picking & Fulfillment) + +**问题严重性**: ⚠️ **物流执行缺失** - 有订单但无法正确发货 + +#### 组件状态(已更新 2026-05-05) + +##### 数据库层 +```prisma +// ❌ 以下模型不存在 + +model StockPicking { + // 出入库单据头(发货单/收货单) + id String @id + type PickingType // OUTBOUND, INBOUND, INTERNAL + orderId String? // 关联销售/采购订单 + status PickingStatus + moves StockMove[] +} + +model StockMove { + // 库存移动明细(从 A 库位 -> B 库位) + id String @id + pickingId String + productId String + fromLocationId String + toLocationId String + quantity Decimal + quantityDone Decimal // 实际完成数量 + status MoveStatus +} + +enum PickingType { + OUTBOUND // 销售出库 + INBOUND // 采购入库 + INTERNAL // 内部调拨 +} +``` + +##### 后端逻辑 +- ❌ 无发货单生成接口 +- ❌ 无拣货单打印 +- ❌ 无批次号扫码绑定 +- ❌ 无波次管理(Wave Management) + +##### 前端界面 +- ❌ 无发货执行工作台 +- ❌ 无扫码枪集成(条码监听器) + +#### 当前变通方案问题 + +现在的代码在 `orders.service.ts` 直接修改 `StockQuant.quantity`: + +```typescript +// ⚠️ 简化处理,缺少单据留痕 +await this.prisma.stockQuant.update({ + where: { id: quant.id }, + data: { quantity: { decrement: lineItem.quantity } } +}); +``` + +**问题**: +- 📝 无出库单据可追溯 +- 🚫 无法支持"分批发货" +- ⚠️ 无实物盘点对账依据 + +#### 推荐行动 +**优先级**: P0 - 核心流程 +**预计工期**: 2 周 +**依赖**: 需配合 Order 发货逻辑重构 + +--- + +### 🔴 4. 库存安全防护机制(Inventory Safety Guards) + +**问题严重性**: ⚠️ **数据一致性风险** - 并发场景可能超卖或负库存 + +#### 当前存在的隐患 + +##### 问题 1: 缺少库存预留(Stock Reservation) + +在 `orders.service.ts:155-169` 的扣减库存逻辑: + +```typescript +// ⚠️ 问题:订单创建时未预留库存 +// 如果订单确认到发货之间时间较长,其他订单可能抢占库存 +const quant = await this.prisma.stockQuant.findFirst({ + where: { + productId: lineItem.productId, + quantity: { gte: lineItem.quantity } + } +}); + +if (!quant) { + throw new ConflictException('库存不足'); +} +``` + +**正确做法应该是**: +```typescript +// ✅ 订单确认时预留,发货时扣减 +model StockQuant { + quantity Decimal // 实物数量 + reservedQuantity Decimal // 已预留数量 + availableQuantity Decimal // 可用 = 实物 - 预留 +} +``` + +##### 问题 2: 缺少并发保护(Concurrency Control) + +当前代码: +```typescript +// ❌ 先查后改,存在 Race Condition +const quant = await findFirst(...); +await update({ where: { id: quant.id }, data: { quantity: newQty } }); +``` + +高并发场景下,两个请求可能同时读到 `quantity=10`,都认为足够,结果扣成负数。 + +**正确做法**: +```typescript +// ✅ 使用 WHERE 条件保证原子性 +const result = await this.prisma.stockQuant.updateMany({ + where: { + id: quantId, + quantity: { gte: decrementAmount } // 乐观锁断言 + }, + data: { quantity: { decrement: decrementAmount } } +}); + +if (result.count === 0) { + throw new ConflictException('库存不足或已被其他订单占用'); +} +``` + +##### 问题 3: 缺少成本计价方法 + +- ❌ 无 FIFO(先进先出) +- ❌ 无 LIFO(后进先出) +- ❌ 无移动加权平均 +- ⚠️ 当前 `StockQuant` 无 `costPrice` 字段 + +#### 推荐行动 +**优先级**: P0 - 数据安全 +**预计工期**: 1 周 +**方案**: +1. 添加 `reservedQuantity` 字段 +2. 改造所有库存变动为原子操作 +3. 引入成本核算层 + +--- + +## 二、中优先级缺失功能 (P1 - 重要但非阻塞) + +### 🟡 1. 多币种支持(Multi-Currency) + +#### 缺失内容 +- ❌ 无 `Currency` 模型 +- ❌ 无 `ExchangeRate` 汇率表 +- ❌ 订单、发票无 `currencyId` 字段 +- ❌ 无汇兑损益自动记账 + +#### 影响 +- 🌏 无法支持跨境贸易 +- 💱 外币交易无法记录 + +#### 推荐行动 +**优先级**: P1 +**预计工期**: 1 周 + +--- + +### 🟡 2. 高级数据网格(Advanced Data Grid) + +#### 规划已完成但未实现 + +在 `docs/plans/EXECUTION_PLAN.md` 明确提到: + +> 引入 AG Grid 或 TanStack Table v8,支持: +> - 行内编辑 (Inline Cell Editing) +> - 虚拟滚动 (Virtual Scrolling) +> - 用户自定义列 + +#### 当前状态 +- ✅ TanStack Table 已引入 +- ❌ **无行内编辑功能** +- ❌ 无虚拟滚动(大数据集会卡顿) +- ❌ 无列偏好持久化 +- ❌ 无吸顶操作栏(Sticky Action Bar) + +#### 影响 +- 📊 数据密集型页面(如订单明细录入)体验差 +- 🖱️ 每次修改都要弹窗,效率低 + +#### 推荐行动 +**优先级**: P1 - 用户体验 +**预计工期**: 1 周 + +--- + +### 🟡 3. PostgreSQL 行级安全(RLS - Row Level Security) + +#### 当前安全隐患 + +虽然已有 `TenantContextMiddleware`,但仅在应用层过滤: + +```typescript +// apps/api/src/core/tenant/tenant-context.middleware.ts +// ⚠️ 应用层控制,不是物理隔离 +req.tenantContext = { companyId: user.companyId }; +``` + +**风险**: +- 如果开发者忘记在 Prisma 查询中加 `where: { companyId }`,会导致数据越权 +- 原始 SQL 查询可能绕过中间件 + +#### 推荐方案 + +在 PostgreSQL 启用 RLS: + +```sql +-- 为每张表启用 RLS +ALTER TABLE "Order" ENABLE ROW LEVEL SECURITY; + +-- 创建策略 +CREATE POLICY tenant_isolation ON "Order" + USING (company_id = current_setting('app.current_tenant')::text); +``` + +配合 Prisma Middleware 自动注入租户上下文: + +```typescript +prisma.$use(async (params, next) => { + await prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`; + return next(params); +}); +``` + +#### 推荐行动 +**优先级**: P1 - 安全加固 +**预计工期**: 3 天 + +--- + +## 三、部分实现功能的缺口 + +### ⚠️ 1. 工作流引擎(Workflow Engine) + +#### 已实现 +- ✅ 基础状态跃迁 (`DRAFT -> CONFIRMED -> SHIPPED`) +- ✅ 事件触发器 (`workflow.action.sale_order.shipped`) + +#### 缺失 +- ❌ 无动态流程定义(目前写死在代码里) +- ❌ 无条件分支(例如:金额 > 10万需要总监审批) +- ❌ 无审批链路可视化 +- ❌ 无回退/驳回功能 +- ❌ 审计日志不完整(缺少操作人、时间戳详情) + +#### 建议 +引入 `WorkflowDefinition` 模型,支持低代码配置工作流。 + +--- + +### ⚠️ 2. 财务模块(Finance Module) + +#### 已实现 +- ✅ 科目表 (`ChartOfAccount`) +- ✅ 会计分录 (`JournalEntry` + `JournalEntryLine`) +- ✅ 借贷平衡校验 + +#### 缺失 +- ❌ 无试算平衡表(Trial Balance) +- ❌ 无期末结账(Period Closing) +- ❌ 无损益表(P&L Statement) +- ❌ 无资产负债表(Balance Sheet) +- ❌ 无现金流量表(Cash Flow Statement) +- ❌ 调整分录(Adjustment Entry)功能不完整 + +#### 建议 +先完成报表生成器,再考虑自动化结账流程。 + +--- + +### ✅ 3. AI 功能集成(AI Features)— 核心逻辑已落地 + +#### 已实现(后端核心 + 前端 UI) +- ✅ Command Palette 界面(apps/web/src/components/ai/CommandPalette.tsx) (`apps/web/src/components/ai/CommandPalette.tsx`) +- ✅ Chat2DashPanel 组件 +- ✅ DocumentDraftUploader(OCR 上传器) + +#### 已实现(核心逻辑,2026-05-05 更新) +- ✅ **LLM 接入已完成** — `LlmAdapterService` 已实现 OpenAI Function Calling +- ✅ **Function Calling 已实现** — 支持 create_resource / transition_workflow / chat2dash_query / chat2sql_read / parse_document_draft +- ✅ **Chat2SQL 已实现** — AIService.chat2sql() + LlmAdapterService.resolveReadSql() 支持只读 SELECT 查询 +- ⚠️ **文档解析部分实现** — 文件名推测 + LLM 降级兜底,尚无真实 OCR + +#### 代码证据(已更新 2026-05-05) + +pps/api/src/core/ai/llm-adapter.service.ts — 完整实现: +- esolveToolCall() — OpenAI Function Calling 路由 +- esolveReadSql() — SQL 只读查询生成(限定 SELECT + companyId 过滤) +- esolveDocumentDraft() — 文档草稿 JSON 生成 + +pps/api/src/core/ai/ai.service.ts — 完整业务逻辑: +- command() — AI 指令路由器(LLM 优先 → 规则引擎兜底) +- chat2dash() — 图表洞察查询 +- chat2sql() — 只读 SQL 查询执行 +- parseDocumentDraft() — 附件解析 +#### 推荐行动 +**优先级**: P1 +**预计工期**: 2 周 +**依赖**: 需选型 LLM 提供商(OpenAI / Azure / 自部署) + +--- + +## 四、完全缺失的功能模块 + +### ❌ 1. CRM 客户关系管理(P2) + +**规划文档**: `README.md:142` 提到 "P2: CRM 线索与商机漏斗" + +**缺失内容**: +- 线索(Lead) +- 商机(Opportunity) +- 销售漏斗(Funnel) +- 活动记录(Activity) +- 客户分级(Customer Segmentation) + +--- + +### ❌ 2. HR 与薪资(P2) + +**规划文档**: `README.md:143` 提到 "P2: HR/Payroll" + +**缺失内容**: +- 员工档案(Employee) +- 考勤打卡(Attendance) +- 请假申请(Leave) +- 薪资计算引擎(Payroll) + +--- + +### ❌ 3. 报表中心(Critical) + +**影响**: 📊 **无法进行经营分析** + +**缺失报表**: +- 财务三大表(损益表、资产负债表、现金流量表) +- 库存周转率分析 +- 销售业绩仪表盘 +- 利润中心分析 +- 供应商绩效分析 + +**当前状态**: +- 仅有 Dashboard 页面的简单统计卡片 +- 无可交互的图表组件(虽有 `recharts` 依赖但未使用) + +--- + +### ❌ 4. DevOps 与测试 + +#### CI/CD 状态(已更新 2026-05-05) +- ✅ `.github/workflows/ci.yml` + `deploy.yml` 已落地 +- ⚠️ 自动化测试覆盖率待提升 +- ⚠️ 部署脚本为占位(deploy.yml),需配置实际服务器 + +#### 测试覆盖率未知 +- 虽有 `.spec.ts` 文件,但不确定是否可运行 +- 无集成测试 +- 无端到端测试(E2E) + +#### 监控与日志 +- ❌ 无日志聚合(ELK/Loki) +- ❌ 无性能监控(APM) +- ❌ 无告警系统 + +--- + +## 五、架构优势与不足总结 + +### ✅ 架构亮点 + +1. **元数据驱动** - 极大减少重复代码 +2. **通用 CRUD API** - 新增模型几乎零成本 +3. **事件驱动** - 模块解耦良好 +4. **多租户设计** - 从第一天就考虑了 SaaS 架构 + +### ⚠️ 架构短板 + +1. **业务完整性不足** - 核心流程有断点(尤其采购) +2. **企业级特性缺失** - 税务、多币种、审批流 +3. **数据安全防护弱** - 并发控制、RLS 不到位 +4. **AI 已部分落地** - Function Calling + Chat2SQL 已实现,OCR 识别待接入真实视觉模型 +5. **可观测性缺失** - 无监控、无报表、无审计追溯 + +--- + +## 六、推荐实施路线图 + +### 第 1-2 周:P0 功能补齐(业务闭环) +- [ ] 实现 PurchaseOrder 模块(采购订单 + 收货单) +- [x] 构建可配置税务引擎(TaxCode 主数据 + 动态税率计算) ✅ 已完成 +- [ ] 改造库存扣减为原子操作(防并发超卖) + +### 第 3-4 周:P0 功能深化(流程完善) +- [ ] 实现 StockPicking/StockMove(出入库单据) +- [ ] 添加库存预留机制(`reservedQuantity`) +- [ ] 完成三单匹配逻辑(PO ↔ GR ↔ Invoice) + +### 第 5-6 周:P1 功能提升(用户体验) +- [ ] 高级数据网格(行内编辑、虚拟滚动) +- [ ] 多币种支持(Currency + ExchangeRate) +- [ ] PostgreSQL RLS 安全加固 + +### 第 7-8 周:AI 功能落地 +- [x] 接入 LLM 提供商(OpenAI) ✅ 已完成 +- [x] 实现 Function Calling(NL2Action) ✅ 已完成 +- [x] 完成 Chat2SQL(Schema 注入 + SQL 生成) ✅ 已完成 + +### 第 9-10 周:报表与分析 +- [ ] 财务三大报表生成器 +- [ ] 销售与库存分析仪表盘 +- [ ] 可交互式数据探索 + +### 第 11-12 周:测试与部署 +- [ ] 编写集成测试(采购→入库→应付→付款 E2E) +- [x] 搭建 CI/CD 管道 ✅ 已完成 +- [ ] 容器化与生产环境部署 + +--- + +## 七、关键文件路径索引 + +### 后端核心 +- 数据库模型: `/home/runner/work/OneERP/OneERP/apps/api/prisma/schema.prisma` +- 通用 CRUD: `/home/runner/work/OneERP/OneERP/apps/api/src/core/crud/` +- 工作流引擎: `/home/runner/work/OneERP/OneERP/apps/api/src/core/workflow/` +- 订单服务: `/home/runner/work/OneERP/OneERP/apps/api/src/orders/` +- 财务服务: `/home/runner/work/OneERP/OneERP/apps/api/src/finance/` + +### 前端核心 +- 动态引擎: `/home/runner/work/OneERP/OneERP/apps/web/src/components/dynamic/` +- AI 组件: `/home/runner/work/OneERP/OneERP/apps/web/src/components/ai/` +- 页面路由: `/home/runner/work/OneERP/OneERP/apps/web/src/app/` + +### 规划文档 +- 项目计划: `/home/runner/work/OneERP/OneERP/docs/plans/PROJECT_PLAN_AND_STATUS.md` +- 执行计划: `/home/runner/work/OneERP/OneERP/docs/plans/EXECUTION_PLAN.md` +- 核心模块开发计划: `/home/runner/work/OneERP/OneERP/docs/plans/CORE_MODULES_DEV_PLAN.md` + +--- + +## 八、结论 + +OneERP 项目的**技术架构设计优秀**,元数据驱动和事件驱动的理念非常先进,但**业务模块实现进度约 50%**。 + +**最紧急的任务是补齐采购模块和税务引擎**,这两个是企业 ERP 的核心刚需,否则系统无法投入实际使用。 + +其次是**库存安全加固**和**Stock Picking 流程**,防止数据不一致和业务断链。 + +AI 功能核心逻辑已落地(LLM Function Calling + Chat2SQL + 规则引擎兜底),但需要配置 OPENAI_API_KEY 才能启用 LLM 路由;未配置时自动降级为规则引擎。 + +报表系统完全缺失,建议在完成核心业务流后优先开发财务三表和销售分析看板。 + +--- + +**生成工具**: Claude Code Agent +**分析基准**: 完整代码库扫描 + 规划文档对比 +**建议审阅人**: 后端架构师、业务分析师、项目经理 diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md new file mode 100644 index 0000000..712992b --- /dev/null +++ b/docs/architecture/ARCHITECTURE.md @@ -0,0 +1,293 @@ +# OneERP 架构全景(Architecture Overview) + +> **最后更新**: 2026-05-05 +> **维护者**: Core Team / 架构师 +> **关联文档**: [STANDARDS.md](./STANDARDS.md) · [PROJECT_PLAN_AND_STATUS.md](../plans/PROJECT_PLAN_AND_STATUS.md) + +--- + +## 1. 项目定位 + +OneERP 是一套面向制造与供应链场景的 **AI Native ERP**,主打: + +- **元数据驱动 UI** — 新增业务模型零页面开发 +- **通用 CRUD 引擎** — `/api/v1/resource/:modelName` 统一网关 +- **事件驱动联动** — 库存、财务、生产通过 EventEmitter 解耦 +- **AI 命令栏** — 自然语言触发业务动作(Function Calling + 规则引擎兜底) + +--- + +## 2. 技术栈 + +| 层级 | 技术 | 版本 | +|------|------|------| +| 后端框架 | NestJS | 11.x | +| ORM | Prisma | 6.x | +| 辅助查询 | Kysely | 0.28.x | +| 数据库 | PostgreSQL | 15 | +| 缓存 | Redis | 7.x | +| 对象存储 | MinIO | latest | +| 前端框架 | Next.js (App Router) | 16.x | +| UI 组件 | shadcn/ui + Radix | latest | +| 状态管理 | Zustand | 5.x | +| 桌面端 | Tauri | 2.x | +| 移动端 | Expo (React Native) | 53.x | +| AI 适配 | OpenAI API (Function Calling) | gpt-4o-mini | +| 构建工具 | Turborepo + npm workspaces | - | + +--- + +## 3. Monorepo 目录结构 + +``` +OneERP/ +├── apps/ +│ ├── api/ # NestJS 后端服务 +│ │ ├── prisma/ # Schema + Migrations +│ │ └── src/ +│ │ ├── core/ # 核心引擎(CRUD / Metadata / Workflow / Audit / AI) +│ │ ├── orders/ +│ │ ├── inventory/ +│ │ ├── finance/ +│ │ ├── production/ +│ │ └── ... +│ ├── web/ # Next.js 前端 +│ │ └── src/ +│ │ ├── app/ # App Router 页面 +│ │ ├── components/ # UI 组件 + 动态引擎 + AI 组件 +│ │ ├── store/ # Zustand 状态管理 +│ │ └── lib/ # 工具函数 +│ ├── mobile/ # Expo 移动端(规划中) +│ └── desktop/ # Tauri 桌面端(规划中) +├── docs/ +│ ├── architecture/ # 架构文档(本文档 + STANDARDS.md) +│ ├── plans/ # 规划与状态文档 +│ ├── analysis/ # 缺失功能分析 +│ └── AI_INSTRUCTIONS.md # AI Agent 协作规范 +└── .github/ + └── workflows/ + ├── ci.yml # CI 流水线(commitlint + validate) + └── deploy.yml # CD 流水线(GHCR 镜像构建) +``` + +--- + +## 4. 核心引擎架构 + +### 4.1 通用 CRUD 引擎 (`core/crud/`) + +``` +/api/v1/resource/:modelName + ├── GET / → 分页查询(支持动态 filter/sort/include) + ├── GET /:id → 单条记录 + ├── POST / → 创建 + ├── PATCH /:id → 更新 + └── DELETE /:id → 删除 +``` + +- **零代码扩展**:新增 Prisma 模型后自动获得完整 CRUD API +- **多租户注入**:`TenantContextMiddleware` 自动注入 `companyId` +- **关联查询**:`?include=items,partner` 动态展开 + +### 4.2 元数据中心 (`core/metadata/`) + +- `ui-schema.ts` 定义全局字段字典(label / type / required / hidden) +- 前端 `DynamicView` 引擎根据 schema 自动渲染表单和列表 +- 支持 `customAttributes` (JSONB) 扩展字段 + +### 4.3 工作流引擎 (`core/workflow/`) + +``` +POST /api/v1/workflow/:modelName/:id/transition + body: { action: "submit" | "confirm" | "ship" | "complete" | "cancel" } +``` + +- 状态跃迁表写在代码中(非数据库配置) +- 事件触发:`workflow.action.sale_order.shipped` → 库存监听器自动扣减 +- 审计日志:每次状态变更记录到 `AuditLog` + +### 4.4 AI 命令服务 (`core/ai/`) + +``` +POST /api/v1/ai/command + body: { input: "帮我创建一个销售订单", dryRun: true } +``` + +**架构分层**: +``` +用户输入 → AIService.command() + ├── LLM 路由(OpenAI Function Calling) + │ └── LlmAdapterService.resolveToolCall() + │ ├── create_resource + │ ├── transition_workflow + │ ├── chat2dash_query + │ ├── chat2sql_read + │ └── parse_document_draft + └── 规则引擎兜底(关键词匹配 + 正则提取) +``` + +- **dry-run 模式**:写操作先生成 Draft 卡片,用户确认后执行 +- **降级策略**:未配置 `OPENAI_API_KEY` 时自动降级为规则引擎 + +--- + +## 5. 数据库架构 + +### 5.1 核心业务模型 + +``` +Company (多租户根) + ├── Partner (客户/供应商) + ├── Product / Material (产品/物料) + ├── Order → OrderItem (销售订单) + ├── Invoice → Payment (发票/收款) + ├── TaxCode (税码主数据) ← 2026-05-05 新增 + ├── StockLocation (库位,树形) + ├── StockQuant (实时存量) + ├── InventoryTransaction (库存流水) + ├── Account (会计科目,树形) + ├── JournalEntry → JournalEntryLine (会计凭证) + └── WorkOrder (生产工单) +``` + +### 5.2 多租户隔离 + +- 所有业务表包含 `companyId` 外键 +- `TenantContextMiddleware` 自动注入租户上下文 +- Prisma 查询自动附加 `WHERE companyId = ?` +- **待增强**:PostgreSQL RLS 物理隔离(计划中) + +### 5.3 税码引擎(2026-05-05 新增) + +```prisma +model TaxCode { + id String @id @default(uuid()) + code String + name String + rate Float @default(0) + isTaxInclusive Boolean @default(true) + isDefault Boolean @default(false) + active Boolean @default(true) + accountId String? + companyId String + @@unique([companyId, code]) +} +``` + +- `finance.service.resolveTaxCode()` 动态查找税码 +- 未指定时回退默认税码或 13% 兜底 +- Order / OrderItem / Invoice 均关联 `taxCodeId` + +--- + +## 6. CI/CD 架构 + +### 6.1 CI 流水线 (`.github/workflows/ci.yml`) + +```yaml +触发条件: + - pull_request → main, develop + - push → develop + +Jobs: + 1. commitlint — 校验提交消息格式 + 2. validate — prisma validate → generate → typecheck → lint → test → build +``` + +### 6.2 CD 流水线 (`.github/workflows/deploy.yml`) + +```yaml +触发条件: + - push → main + - workflow_dispatch (手动) + +Jobs: + 1. build-api — 构建 API Docker 镜像 → 推送 GHCR + 2. build-web — 构建 Web Docker 镜像 → 推送 GHCR + 3. deploy — 部署占位(需配置 SSH 或 Docker Compose) +``` + +--- + +## 7. AI 集成架构 + +### 7.1 后端 AI 模块 (`apps/api/src/core/ai/`) + +| 文件 | 职责 | +|------|------| +| `ai.module.ts` | NestJS 模块注册 | +| `ai.controller.ts` | REST API 端点 | +| `ai.service.ts` | 业务逻辑(指令路由 + 工具执行) | +| `llm-adapter.service.ts` | LLM 调用适配(OpenAI API) | +| `dto/ai-command.dto.ts` | 请求/响应 DTO | + +### 7.2 前端 AI 组件 (`apps/web/src/components/ai/`) + +| 文件 | 职责 | +|------|------| +| `CommandPalette.tsx` | Cmd+K 命令面板 | +| `Chat2DashPanel.tsx` | 图表洞察面板 | + +### 7.3 AI Tools 注册表 + +| Tool Name | 描述 | 读/写 | +|-----------|------|-------| +| `create_resource` | 创建任意模型记录 | 写 | +| `transition_workflow` | 执行工作流状态跃迁 | 写 | +| `chat2dash_query` | 图表洞察查询 | 读 | +| `chat2sql_read` | 自然语言转只读 SQL | 读 | +| `parse_document_draft` | 附件解析生成草稿 | 读 | + +--- + +## 8. 事件驱动架构 + +``` +EventEmitter (NestJS) + ├── workflow.action.* → 审计日志 + ├── order.shipped → 库存扣减 + 财务凭证 + ├── stock.depleted → 财务成本结转 + └── invoice.posted → 应收账款更新 +``` + +- 使用 `@nestjs/event-emitter` + `eventemitter2` +- 失败事件进入 DLQ (`EventDlq`) 待重试 + +--- + +## 9. 部署架构 + +### 9.1 本地开发 + +```bash +docker compose up -d # Postgres + Redis + MinIO +cd apps/api && npm run start:dev +cd apps/web && npm run dev +``` + +### 9.2 生产部署 + +``` +GitHub Actions + → Build Docker Images + → Push to GHCR (ghcr.io//oneerp-api, ghcr.io//oneerp-web) + → Deploy via SSH / Docker Compose (待配置) +``` + +--- + +## 10. 当前架构状态总结 + +| 维度 | 状态 | 备注 | +|------|------|------| +| 核心引擎 | ✅ 90% | CRUD / Metadata / Workflow / Audit | +| 业务模块 | ⚠️ 60% | 销售/库存/财务已有,采购缺失 | +| AI 集成 | ✅ 65% | Function Calling + Chat2SQL 已落地 | +| CI/CD | ✅ 80% | CI 流水线完整,CD 部署待配置 | +| 税务引擎 | ✅ 60% | TaxCode 模型 + 动态解析已落地 | +| 安全加固 | ⚠️ 40% | 应用层租户隔离,RLS 待增强 | +| 可观测性 | ⚠️ 20% | 基础日志,无 APM/告警 | + +--- + +**文档维护说明**: 本文档应随代码架构变更同步更新。重大架构决策请在 `docs/plans/` 中记录。 diff --git a/docs/plans/PROJECT_PLAN_AND_STATUS.md b/docs/plans/PROJECT_PLAN_AND_STATUS.md index e646237..e0dbe86 100644 --- a/docs/plans/PROJECT_PLAN_AND_STATUS.md +++ b/docs/plans/PROJECT_PLAN_AND_STATUS.md @@ -2,7 +2,7 @@ > 本文档汇集整合了先前的架构分析 (`analyze.md`)、执行规划 (`plan.md`) 及各阶段交接文档 (`PHASE_X_HANDOVER.md`),作为本项目的唯一核心蓝图与状态追踪板。 -## 一、 系统执行总进度与各阶段状态 (更新于 2026-03-23) +## 一、 系统执行总进度与各阶段状态 (更新于 2026-05-05) ### 🟢 阶段零:内核引擎打造(已验收收口) @@ -29,16 +29,11 @@ * **Kysely 实时台账 API**:`GET /api/inventory/realtime-ledger` 已接入 `KyselyService` 原生聚合 SQL,按物料×库位汇总 `totalQty` / `stockValue`,并在服务层计算 `isLow` / `isOut` 低库存标志。 * **库存台账前端仪表盘**:重构 `apps/web/src/app/dashboard/inventory/page.tsx` 为完整的实时库存看板,集成快速过滤(低库存/零库存)、分类筛选、关键字搜索和红绿色预警着色。 -### 🚧 阶段三 (剩余):AI 功能深度集成(接下来即将进入) +### 🟢 阶段三:AI 功能深度集成(核心逻辑已落地,2026-05-05 更新) -* **全局 AI Command Bar (前端)**:描述:在前端顶部栏实现类似 Command Palette 的输入框,并接入自然语言理解。用户可输入“帮我创建一个销售订单,卖给微软10台服务器”。直接文字/语音下达自然语言指令。 -* **Agent 意图与 Function Calling (后端)**:接入 LLM 的 Tool Call,借助阶段零生成的通用 CRUD 自动执行业务流配置。**NL2Action 控制器(后端)** * **描述** :开发意图识别和 Function Calling 服务。当收到文字时,提取实体映射到 [api](vscode-file://vscode-app/c:/Users/INDEX/AppData/Local/Programs/Microsoft%20VS%20Code/07ff9d6178/resources/app/out/vs/code/electron-browser/workbench/workbench.html) 的相关 Controller(此时调用大模型的 tool/function-calling 机制解析 JSON 参数并调用对应的业务流)。 -* **Smart Dashboard (Chat2SQL)**:实时根据语义绘制分析图表。**RAG 数据分析(Text-to-SQL)** * **描述** :将数据库 Schema 喂给大模型(或微调专属模型),支持用户直接在 Dashboard 提问(例如:“上个月哪个部门采购的物料最多?”),系统自动生成报表或数据透视表。 - -### ⚪ 阶段四:测试、优化与部署(计划中) - -* 端到端流转测试,编写集成测试,尤其是“采购->收货入库->产生应付账款”整个资金链路的断言测试。 -* 容器化服务剥离,优化 [docker-compose.yml](vscode-file://vscode-app/c:/Users/INDEX/AppData/Local/Programs/Microsoft%20VS%20Code/07ff9d6178/resources/app/out/vs/code/electron-browser/workbench/workbench.html),拆分服务为 API, Web, Postgres, Redis 等,并确保自动化部署脚本完备。,基于 `docker-compose` 和 CI/CD 进行多端部署打包。 +* **全局 AI Command Bar (前端)**:✅ 已落地 — CommandPalette.tsx 支持 Cmd+K 快捷键触发自然语言指令。 +* **Agent 意图与 Function Calling (后端)**:✅ 已落地 — llm-adapter.service.ts 实现 OpenAI Function Calling 路由,ai.service.ts 注册 5 个 AI Tools。未配置 OPENAI_API_KEY 时自动降级为规则引擎。 +* **Smart Dashboard (Chat2SQL)**:✅ 已落地 — chat2dash() 支持图表洞察查询,chat2sql() 支持自然语言转只读 SELECT SQL。 --- From a427d248d8fcf402652c4802be27f1d2d3678687 Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Tue, 5 May 2026 14:56:23 +0800 Subject: [PATCH 05/54] feat(metadata): register PurchaseOrder and PurchaseOrderLine DynamicView schemas - Add purchaseOrder schema with form sections (basic info, amounts, notes), list view, and kanban (6 status columns) - Add purchaseOrderLine schema with form, list views and reference fields to purchaseOrder and material - Add sidebar nav entry for purchase orders (ClipboardList icon, /dashboard/dynamic/purchaseOrder) --- .../api/src/core/metadata/metadata.service.ts | 119 ++++++++++++++++++ apps/web/src/app/dashboard/layout.tsx | 3 +- 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/apps/api/src/core/metadata/metadata.service.ts b/apps/api/src/core/metadata/metadata.service.ts index 662a5a0..ad3043e 100644 --- a/apps/api/src/core/metadata/metadata.service.ts +++ b/apps/api/src/core/metadata/metadata.service.ts @@ -606,6 +606,125 @@ export class MetadataService { }, }, ], + [ + 'purchaseOrder', + { + model: 'purchaseOrder', + label: '采购订单', + description: '采购订单主数据,管理供应商采购业务。', + companyScoped: true, + fields: [ + { name: 'orderNo', label: '采购单号', type: 'string', required: true }, + { + name: 'partnerId', + label: '供应商', + type: 'reference', + required: true, + reference: { + model: 'partner', + labelField: 'name', + valueField: 'id', + relationField: 'partner', + }, + }, + { + name: 'taxCodeId', + label: '税码', + type: 'reference', + reference: { + model: 'taxCode', + labelField: 'name', + valueField: 'id', + relationField: 'taxCode', + }, + }, + { name: 'status', label: '状态', type: 'string' }, + { name: 'subTotal', label: '未税金额', type: 'number' }, + { name: 'taxTotal', label: '税额', type: 'number' }, + { name: 'totalAmount', label: '总金额', type: 'number' }, + { name: 'orderDate', label: '下单日期', type: 'date' }, + { name: 'expectedDate', label: '预计到货', type: 'date' }, + { name: 'notes', label: '备注', type: 'text' }, + ], + views: { + form: { + sections: [ + { title: '基础信息', fields: ['orderNo', 'partnerId', 'taxCodeId', 'status', 'orderDate', 'expectedDate'] }, + { title: '金额信息', fields: ['subTotal', 'taxTotal', 'totalAmount'] }, + { title: '备注', fields: ['notes'] }, + ], + }, + list: { + columns: ['orderNo', 'partnerId', 'status', 'subTotal', 'taxTotal', 'totalAmount', 'orderDate', 'expectedDate'], + defaultSort: { createdAt: 'desc' }, + searchFields: ['orderNo', 'status'], + }, + kanban: { + statusField: 'status', + columns: [ + { value: 'DRAFT', label: '草稿', color: 'bg-slate-50' }, + { value: 'PENDING', label: '待审批', color: 'bg-amber-50' }, + { value: 'APPROVED', label: '已批准', color: 'bg-sky-50' }, + { value: 'ORDERED', label: '已下单', color: 'bg-indigo-50' }, + { value: 'RECEIVED', label: '已收货', color: 'bg-emerald-50' }, + { value: 'CANCELLED', label: '已取消', color: 'bg-rose-50' }, + ], + }, + }, + }, + ], + [ + 'purchaseOrderLine', + { + model: 'purchaseOrderLine', + label: '采购订单行', + description: '采购订单明细行项目。', + companyScoped: true, + fields: [ + { + name: 'purchaseOrderId', + label: '采购订单', + type: 'reference', + required: true, + reference: { + model: 'purchaseOrder', + labelField: 'orderNo', + valueField: 'id', + relationField: 'purchaseOrder', + }, + }, + { + name: 'materialId', + label: '物料', + type: 'reference', + required: true, + reference: { + model: 'material', + labelField: 'name', + valueField: 'id', + relationField: 'material', + }, + }, + { name: 'quantity', label: '数量', type: 'number', required: true }, + { name: 'unitPrice', label: '单价', type: 'number', required: true }, + { name: 'subTotal', label: '未税金额', type: 'number' }, + { name: 'taxAmount', label: '税额', type: 'number' }, + { name: 'totalPrice', label: '含税金额', type: 'number' }, + { name: 'receivedQty', label: '已收货数量', type: 'number' }, + { name: 'notes', label: '备注', type: 'text' }, + ], + views: { + form: { + fields: ['purchaseOrderId', 'materialId', 'quantity', 'unitPrice', 'subTotal', 'taxAmount', 'totalPrice', 'receivedQty', 'notes'], + }, + list: { + columns: ['purchaseOrderId', 'materialId', 'quantity', 'unitPrice', 'subTotal', 'taxAmount', 'totalPrice', 'receivedQty'], + defaultSort: { createdAt: 'desc' }, + searchFields: ['purchaseOrderId', 'materialId'], + }, + }, + }, + ], ]); async listSchemas(companyId?: string) { diff --git a/apps/web/src/app/dashboard/layout.tsx b/apps/web/src/app/dashboard/layout.tsx index 338f5d9..83c5ce3 100644 --- a/apps/web/src/app/dashboard/layout.tsx +++ b/apps/web/src/app/dashboard/layout.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef } from 'react'; import { useRouter, usePathname } from 'next/navigation'; -import { Building2, Package, ShoppingCart, Users, Settings, FileText, LayoutDashboard, LogOut, PanelRight, Table } from 'lucide-react'; +import { Building2, Package, ShoppingCart, Users, Settings, FileText, LayoutDashboard, LogOut, PanelRight, Table, ClipboardList } from 'lucide-react'; import { useAuthStore } from '../../store/authStore'; import { CommandPalette } from '../../components/ai/CommandPalette'; import { WorkspaceTabs } from '../../components/ui/WorkspaceTabs'; @@ -19,6 +19,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod const navItems = [ { icon: LayoutDashboard, label: '概览', href: '/dashboard' }, { icon: ShoppingCart, label: '销售打单', href: '/dashboard/sales' }, + { icon: ClipboardList, label: '采购订单', href: '/dashboard/dynamic/purchaseOrder' }, { icon: Package, label: '生产与库存', href: '/dashboard/inventory' }, { icon: FileText, label: '图纸文档', href: '/dashboard/files' }, { icon: Users, label: '客户管理', href: '/dashboard/customers' }, From 39797c268c8e60ad84738e138d23f8369e6bbbb4 Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Tue, 5 May 2026 14:59:21 +0800 Subject: [PATCH 06/54] test(web): add smoke tests for login, DynamicView, and order detail timeline - LoginPage: render, default credentials, submit, error states, loading - DynamicView: list/create/edit/reference field include generation - OrderDetailPage: order info, timeline events, status transitions, empty states - Unified web-smoke.test.tsx aggregating all core frontend paths --- apps/web/src/__tests__/web-smoke.test.tsx | 424 ++++++++++++++++++ .../orders/__tests__/OrderDetailPage.test.tsx | 197 ++++++++ .../app/login/__tests__/LoginPage.test.tsx | 151 +++++++ 3 files changed, 772 insertions(+) create mode 100644 apps/web/src/__tests__/web-smoke.test.tsx create mode 100644 apps/web/src/app/dashboard/orders/__tests__/OrderDetailPage.test.tsx create mode 100644 apps/web/src/app/login/__tests__/LoginPage.test.tsx diff --git a/apps/web/src/__tests__/web-smoke.test.tsx b/apps/web/src/__tests__/web-smoke.test.tsx new file mode 100644 index 0000000..92eb95d --- /dev/null +++ b/apps/web/src/__tests__/web-smoke.test.tsx @@ -0,0 +1,424 @@ +/** + * Web 冒烟测试 — 核心前端功能可用性验证 + * + * 覆盖: + * 1. 登录页:渲染 + 默认值 + 提交 + 错误 + * 2. DynamicView:列表加载 / 新建 / 编辑 / 引用字段选择 + * 3. 订单详情 timeline:事件渲染 / 空态 / 批注提交 + * + * 这是一个聚合层面的冒烟测试,验证各核心页面/组件的关键路径。 + */ + +/* ======================================================== + Mock 公共依赖 + ======================================================== */ + +/* localStorage */ +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: jest.fn((k: string) => store[k] ?? null), + setItem: jest.fn((k: string, v: string) => { store[k] = v; }), + removeItem: jest.fn((k: string) => { delete store[k]; }), + clear: jest.fn(() => { store = {}; }), + get length() { return Object.keys(store).length; }, + key: jest.fn((i: number) => Object.keys(store)[i] ?? null), + }; +})(); +Object.defineProperty(global, 'localStorage', { value: localStorageMock }); +Object.defineProperty(global, 'window', { + value: { + location: { href: '' }, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + writable: true, +}); + +/* next/navigation */ +const mockRouterPush = jest.fn(); +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockRouterPush, back: jest.fn() }), + useParams: () => ({ id: 'order-001' }), +})); + +/* Dynamic resource mocks */ +const mockFetchSchema = jest.fn(); +const mockFetchResourceList = jest.fn(); +const mockCreateResource = jest.fn(); +const mockUpdateResource = jest.fn(); +jest.mock('@/lib/dynamic-resource', () => ({ + fetchSchema: (...a: any[]) => mockFetchSchema(...a), + fetchResourceList: (...a: any[]) => mockFetchResourceList(...a), + createResource: (...a: any[]) => mockCreateResource(...a), + updateResource: (...a: any[]) => mockUpdateResource(...a), +})); + +/* API mock */ +const mockApiGet = jest.fn(); +const mockApiPost = jest.fn(); +jest.mock('@/lib/api', () => ({ + default: { get: (...a: any[]) => mockApiGet(...a), post: (...a: any[]) => mockApiPost(...a) }, +})); + +/* Child component mocks for DynamicView */ +jest.mock('@/components/core/ListEngine', () => ({ + ListEngine: ({ onRowClick, onSearchChange }: any) => ( +
+ + onSearchChange?.(e.target.value)} /> +
+ ), +})); +jest.mock('@/components/core/KanbanEngine', () => ({ + KanbanEngine: () =>
, +})); +jest.mock('@/components/core/FormEngine', () => ({ + FormEngine: ({ value, onChange, onSubmit }: any) => ( +
+ onChange?.({ ...value, name: e.target.value })} /> + +
+ ), +})); +jest.mock('@/components/ui/Sheet', () => ({ + Sheet: ({ open, children, onClose, title }: any) => + open ? ( +
+ {title} + + {children} +
+ ) : null, +})); + +/* ======================================================== + 测试数据 + ======================================================== */ + +import type { UiSchema } from '@/lib/ui-schema'; + +const mockSchema: UiSchema = { + model: 'Product', label: '产品', description: '产品管理', + fields: [ + { name: 'id', label: 'ID', type: 'string' }, + { name: 'name', label: '名称', type: 'string', required: true }, + { name: 'status', label: '状态', type: 'select', options: [{ label: '草稿', value: 'Draft' }, { label: '已发布', value: 'Published' }] }, + { name: 'categoryId', label: '分类', type: 'reference', reference: { model: 'Category', labelField: 'name', valueField: 'id', relationField: 'category' } }, + ], + views: { + form: { fields: ['name', 'status', 'categoryId'] }, + list: { columns: ['id', 'name', 'status', 'categoryId'], searchFields: ['name'] }, + }, +}; + +const mockListResponse = { + data: [{ id: '1', name: '产品A', status: 'Published' }, { id: '2', name: '产品B', status: 'Draft' }], + total: 2, page: 1, limit: 20, totalPages: 1, +}; + +const mockRefOptions = { + data: [{ id: 'cat-1', name: '电子元器件' }, { id: 'cat-2', name: '机械零件' }], + total: 2, page: 1, limit: 200, totalPages: 1, +}; + +const mockTimelineEvents = { + data: { + events: [ + { id: 'ev1', action: 'CREATED', createdAt: '2025-01-01T00:00:00Z', user: { name: 'admin' } }, + { id: 'ev2', action: 'UPDATED', createdAt: '2025-01-02T00:00:00Z', user: { email: 'user@test.com' } }, + ], + }, +}; + +/* ======================================================== + Smoke 1: DynamicView 核心流程 + ======================================================== */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DynamicView } from '@/components/core/DynamicView'; + +describe('Smoke · DynamicView 核心流程', () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorageMock.clear(); + mockFetchSchema.mockResolvedValue(mockSchema); + mockFetchResourceList.mockResolvedValue(mockListResponse); + mockApiGet.mockResolvedValue(mockTimelineEvents); + mockApiPost.mockResolvedValue({}); + mockCreateResource.mockResolvedValue({ id: 'new-1', name: '新产品', status: 'Draft' }); + mockUpdateResource.mockResolvedValue({ id: '1', name: '更新名', status: 'Published' }); + }); + + it('schema 加载成功后显示标题和列表', async () => { + render(); + await waitFor(() => { expect(screen.getByText('产品')).toBeInTheDocument(); }); + expect(screen.getByTestId('list-engine')).toBeInTheDocument(); + }); + + it('reference 字段自动生成 include 参数', async () => { + render(); + await waitFor(() => { expect(mockFetchResourceList).toHaveBeenCalled(); }); + const lastCall = mockFetchResourceList.mock.calls[mockFetchResourceList.mock.calls.length - 1]; + expect(lastCall[1].include).toHaveProperty('category'); + }); + + it('新建记录:点击新建 → 表单打开 → 保存调用 createResource', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { expect(screen.getByTestId('list-engine')).toBeInTheDocument(); }); + await user.click(screen.getByText('新建 / 编辑')); + await waitFor(() => { expect(screen.getByTestId('sheet')).toBeInTheDocument(); }); + expect(screen.getByTestId('sheet-title').textContent).toContain('新建'); + await user.click(screen.getByText(/保存/)); + await waitFor(() => { expect(mockCreateResource).toHaveBeenCalledWith('Product', expect.objectContaining({})); }); + }); + + it('编辑记录:点击行 → 表单打开 → 保存调用 updateResource', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { expect(screen.getByTestId('list-engine')).toBeInTheDocument(); }); + await user.click(screen.getByTestId('row-click')); + await waitFor(() => { expect(screen.getByTestId('sheet')).toBeInTheDocument(); }); + expect(screen.getByTestId('sheet-title').textContent).toContain('编辑'); + await user.click(screen.getByText(/保存/)); + await waitFor(() => { + expect(mockUpdateResource).toHaveBeenCalledWith('Product', 'row-1', expect.objectContaining({ id: 'row-1' })); + }); + }); + + it('保存后刷新列表', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { expect(screen.getByTestId('list-engine')).toBeInTheDocument(); }); + const initCount = mockFetchResourceList.mock.calls.length; + await user.click(screen.getByText('新建 / 编辑')); + await waitFor(() => { expect(screen.getByTestId('sheet')).toBeInTheDocument(); }); + await user.click(screen.getByText(/保存/)); + await waitFor(() => { expect(mockFetchResourceList.mock.calls.length).toBeGreaterThan(initCount); }); + }); + + it('timeline 事件渲染', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { expect(screen.getByTestId('list-engine')).toBeInTheDocument(); }); + await user.click(screen.getByTestId('row-click')); + await waitFor(() => { + expect(screen.getByText('CREATED')).toBeInTheDocument(); + expect(screen.getByText('admin')).toBeInTheDocument(); + }); + }); + + it('timeline 为空显示占位', async () => { + mockApiGet.mockResolvedValue({ data: { events: [] } }); + const user = userEvent.setup(); + render(); + await waitFor(() => { expect(screen.getByTestId('list-engine')).toBeInTheDocument(); }); + await user.click(screen.getByTestId('row-click')); + await waitFor(() => { expect(screen.getByText('暂无时间线事件。')).toBeInTheDocument(); }); + }); + + it('提交批注后调用 comment API', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { expect(screen.getByTestId('list-engine')).toBeInTheDocument(); }); + await user.click(screen.getByTestId('row-click')); + await waitFor(() => { expect(screen.getByPlaceholderText('写入团队批注...')).toBeInTheDocument(); }); + await user.type(screen.getByPlaceholderText('写入团队批注...'), '测试批注'); + await user.click(screen.getByText('发布批注')); + await waitFor(() => { + expect(mockApiPost).toHaveBeenCalledWith('/v1/timeline/Product/row-1/comment', { content: '测试批注' }); + }); + }); + + it('schema 加载失败显示错误', async () => { + mockFetchSchema.mockRejectedValue(new Error('Network error')); + render(); + await waitFor(() => { expect(screen.getByText('Network error')).toBeInTheDocument(); }); + }); +}); + +/* ======================================================== + Smoke 2: 引用字段选择 + ======================================================== */ + +describe('Smoke · 引用字段选择', () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorageMock.clear(); + mockFetchSchema.mockResolvedValue(mockSchema); + mockFetchResourceList.mockResolvedValue(mockListResponse); + mockApiGet.mockResolvedValue(mockTimelineEvents); + mockApiPost.mockResolvedValue({}); + }); + + it('schema 含 reference 字段时 fetchResourceList 带 include', async () => { + render(); + await waitFor(() => { expect(mockFetchResourceList).toHaveBeenCalled(); }); + const call = mockFetchResourceList.mock.calls[0]; + expect(call[0]).toBe('Product'); + expect(call[1].include).toEqual({ category: true }); + }); + + it('schema 无 reference 字段时 include 为空对象', async () => { + const noRefSchema: UiSchema = { + ...mockSchema, + fields: mockSchema.fields.filter(f => f.type !== 'reference'), + views: { ...mockSchema.views, list: { ...mockSchema.views.list, columns: ['id', 'name', 'status'] } }, + }; + mockFetchSchema.mockResolvedValue(noRefSchema); + render(); + await waitFor(() => { expect(mockFetchResourceList).toHaveBeenCalled(); }); + const call = mockFetchResourceList.mock.calls[0]; + expect(call[1].include).toEqual({}); + }); +}); + +/* ======================================================== + Smoke 3: 登录页 + ======================================================== */ + +// authStore mock — 同时支持 Login (setAuth) 和 OrderDetail (currentCompanyId) +const mockSetAuth = jest.fn(); +jest.mock('@/store/authStore', () => ({ + useAuthStore: Object.assign( + (selector: any) => selector({ + setAuth: mockSetAuth, + currentCompanyId: 'c1', + token: 'test-token', + user: { id: 'u1', username: 'admin', role: 'admin' }, + companies: [{ id: 'c1', name: 'TestCo', role: 'owner' }], + }), + { getState: jest.fn() }, + ), +})); + +// 登录页需要单独 mock api(post) +// 已在顶部 mock 了 api.default + +import LoginPage from '@/app/login/page'; + +describe('Smoke · 登录页', () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorageMock.clear(); + mockApiPost.mockResolvedValue({ + data: { + accessToken: 'jwt-token-abc', + user: { id: 'u1', username: 'admin', role: 'admin' }, + companies: [{ id: 'c1', name: 'TestCo', role: 'owner' }], + }, + }); + }); + + it('渲染标题和默认账号', () => { + render(); + expect(screen.getByText('智能制造 EIP 全局系统')).toBeInTheDocument(); + const emailInput = screen.getByPlaceholderText('请输入账号') as HTMLInputElement; + expect(emailInput.value).toBe('admin@erp.com'); + }); + + it('登录成功调用 API → setAuth → 跳转', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('安全登入')); + await waitFor(() => { + expect(mockApiPost).toHaveBeenCalledWith('/auth/login', { email: 'admin@erp.com', password: 'admin' }); + }); + await waitFor(() => { + expect(mockSetAuth).toHaveBeenCalledWith('jwt-token-abc', expect.any(Object), expect.any(Array)); + }); + await waitFor(() => { + expect(mockRouterPush).toHaveBeenCalledWith('/dashboard'); + }); + }); + + it('登录失败显示错误', async () => { + mockApiPost.mockRejectedValueOnce({ response: { data: { message: '用户名或密码错误' } } }); + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('安全登入')); + await waitFor(() => { + expect(screen.getByText('用户名或密码错误')).toBeInTheDocument(); + }); + }); +}); + +/* ======================================================== + Smoke 4: 订单详情 Timeline + ======================================================== */ + +const mockToastError = jest.fn(); +const mockToastSuccess = jest.fn(); +jest.mock('react-hot-toast', () => ({ + __esModule: true, + default: { error: (...a: any[]) => mockToastError(...a), success: (...a: any[]) => mockToastSuccess(...a) }, +})); + +const mockOrderData = { + id: 'order-001', orderNo: 'SO-2025-0001', status: 'DRAFT', totalAmount: 50000, + expectedDate: '2025-06-01T00:00:00Z', notes: '优先安排', createdAt: '2025-01-15T08:00:00Z', + partner: { id: 'p1', name: '测试客户A', contact: '张三', phone: '13800000000' }, + salesPerson: { id: 'sp1', name: '李销售' }, + items: [{ id: 'item-1', productId: 'PROD-001', quantity: 10, unitPrice: 5000, totalPrice: 50000 }], + workOrders: [], invoices: [], +}; + +import OrderDetailPage from '@/app/dashboard/orders/[id]/page'; + +describe('Smoke · 订单详情 Timeline', () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorageMock.clear(); + mockApiGet.mockImplementation((url: string) => { + if (url === '/orders/order-001') return Promise.resolve({ data: mockOrderData }); + if (url === '/orders/order-001/timeline') return Promise.resolve({ + data: { events: [{ id: 'ev1', action: 'CREATED', createdAt: '2025-01-15T08:00:00Z', user: { name: 'admin' } }] }, + }); + return Promise.resolve({ data: {} }); + }); + mockApiPost.mockResolvedValue({}); + }); + + it('加载订单号和状态', async () => { + render(); + await waitFor(() => { expect(screen.getByText('SO-2025-0001')).toBeInTheDocument(); }); + expect(screen.getByText('草稿')).toBeInTheDocument(); + }); + + it('Timeline 事件渲染', async () => { + render(); + await waitFor(() => { expect(screen.getByText('CREATED')).toBeInTheDocument(); }); + expect(screen.getByText('admin')).toBeInTheDocument(); + }); + + it('Timeline 为空显示占位', async () => { + mockApiGet.mockImplementation((url: string) => { + if (url === '/orders/order-001') return Promise.resolve({ data: mockOrderData }); + return Promise.resolve({ data: { events: [] } }); + }); + render(); + await waitFor(() => { expect(screen.getByText('暂无动态记录。')).toBeInTheDocument(); }); + }); + + it('状态流转触发 workflow API', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { expect(screen.getByText('提交订单')).toBeInTheDocument(); }); + await user.click(screen.getByText('提交订单')); + await waitFor(() => { + expect(mockApiPost).toHaveBeenCalledWith('/v1/workflow/order/order-001/transition', { action: 'submit' }); + }); + }); + + it('加载失败跳转回列表', async () => { + mockApiGet.mockImplementation((url: string) => { + if (url === '/orders/order-001') return Promise.reject(new Error('fail')); + return Promise.resolve({ data: { events: [] } }); + }); + render(); + await waitFor(() => { expect(mockRouterPush).toHaveBeenCalledWith('/dashboard/orders'); }); + }); +}); diff --git a/apps/web/src/app/dashboard/orders/__tests__/OrderDetailPage.test.tsx b/apps/web/src/app/dashboard/orders/__tests__/OrderDetailPage.test.tsx new file mode 100644 index 0000000..3717cee --- /dev/null +++ b/apps/web/src/app/dashboard/orders/__tests__/OrderDetailPage.test.tsx @@ -0,0 +1,197 @@ +/** + * OrderDetailPage.tsx — 冒烟测试 + * + * 覆盖: + * 1. 渲染:加载态 → 订单号、状态标签、基础信息 + * 2. Timeline:事件渲染、空态占位 + * 3. 产品明细:表格行渲染、金额汇总 + * 4. 关联卡片:工单 / 发票 空态 + * 5. 状态流转:按钮显示 → 点击提交 + * 6. 加载失败:跳转回列表 + */ + +/* ---------- Mock next/navigation ---------- */ +const mockPush = jest.fn(); +const mockBack = jest.fn(); +jest.mock('next/navigation', () => ({ + useParams: () => ({ id: 'order-001' }), + useRouter: () => ({ push: mockPush, back: mockBack }), +})); + +/* ---------- Mock API ---------- */ +const mockApiGet = jest.fn(); +const mockApiPost = jest.fn(); +jest.mock('../../../../lib/api', () => ({ + default: { get: (...a: any[]) => mockApiGet(...a), post: (...a: any[]) => mockApiPost(...a) }, +})); + +/* ---------- Mock authStore ---------- */ +jest.mock('../../../../store/authStore', () => ({ + useAuthStore: Object.assign( + (selector: any) => selector({ currentCompanyId: 'c1' }), + { getState: jest.fn() }, + ), +})); + +/* ---------- Mock toast ---------- */ +const mockToastError = jest.fn(); +const mockToastSuccess = jest.fn(); +jest.mock('react-hot-toast', () => ({ + __esModule: true, + default: { error: (...a: any[]) => mockToastError(...a), success: (...a: any[]) => mockToastSuccess(...a) }, +})); + +/* ---------- 测试数据 ---------- */ +const mockOrder = { + id: 'order-001', + orderNo: 'SO-2025-0001', + status: 'DRAFT', + totalAmount: 50000, + expectedDate: '2025-06-01T00:00:00Z', + notes: '请优先安排生产', + createdAt: '2025-01-15T08:00:00Z', + partner: { id: 'p1', name: '测试客户A', contact: '张三', phone: '13800000000' }, + salesPerson: { id: 'sp1', name: '李销售' }, + items: [ + { id: 'item-1', productId: 'PROD-001', quantity: 10, unitPrice: 5000, totalPrice: 50000 }, + ], + workOrders: [], + invoices: [], +}; + +const mockTimelineEvents = { + data: { + events: [ + { id: 'ev1', action: 'CREATED', createdAt: '2025-01-15T08:00:00Z', user: { name: 'admin' } }, + { id: 'ev2', action: 'UPDATED', createdAt: '2025-01-16T10:30:00Z', user: { email: 'sales@test.com' } }, + ], + }, +}; + +/* ---------- Tests ---------- */ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import OrderDetailPage from '../page'; + +describe('OrderDetailPage 冒烟测试', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockApiGet.mockImplementation((url: string) => { + if (url === '/orders/order-001') return Promise.resolve({ data: mockOrder }); + if (url === '/orders/order-001/timeline') return Promise.resolve(mockTimelineEvents); + return Promise.resolve({ data: {} }); + }); + mockApiPost.mockResolvedValue({}); + }); + + it('加载成功后显示订单号和状态标签', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('SO-2025-0001')).toBeInTheDocument(); + }); + expect(screen.getByText('草稿')).toBeInTheDocument(); + }); + + it('显示基础信息:客户名称、销售负责人', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('测试客户A')).toBeInTheDocument(); + }); + expect(screen.getByText('李销售')).toBeInTheDocument(); + }); + + it('显示产品明细表格和金额', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('PROD-001')).toBeInTheDocument(); + }); + expect(screen.getByText('¥50,000')).toBeInTheDocument(); + }); + + it('Timeline 渲染事件', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('CREATED')).toBeInTheDocument(); + }); + expect(screen.getByText('admin')).toBeInTheDocument(); + }); + + it('Timeline 为空时显示占位', async () => { + mockApiGet.mockImplementation((url: string) => { + if (url === '/orders/order-001') return Promise.resolve({ data: mockOrder }); + if (url === '/orders/order-001/timeline') return Promise.resolve({ data: { events: [] } }); + return Promise.resolve({ data: {} }); + }); + render(); + await waitFor(() => { + expect(screen.getByText('暂无动态记录。')).toBeInTheDocument(); + }); + }); + + it('DRAFT 状态显示"提交订单"按钮', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('提交订单')).toBeInTheDocument(); + }); + }); + + it('点击"提交订单"触发状态流转', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('提交订单')).toBeInTheDocument(); + }); + await user.click(screen.getByText('提交订单')); + await waitFor(() => { + expect(mockApiPost).toHaveBeenCalledWith( + '/v1/workflow/order/order-001/transition', + { action: 'submit' }, + ); + }); + }); + + it('关联工单和发票为空时显示占位', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('暂无工单记录')).toBeInTheDocument(); + }); + expect(screen.getByText('暂无发票记录')).toBeInTheDocument(); + }); + + it('加载失败后跳转回订单列表', async () => { + mockApiGet.mockImplementation((url: string) => { + if (url === '/orders/order-001') return Promise.reject(new Error('Not found')); + return Promise.resolve({ data: { events: [] } }); + }); + render(); + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('加载订单详情失败'); + expect(mockPush).toHaveBeenCalledWith('/dashboard/orders'); + }); + }); + + it('PENDING 状态显示"开始生产"按钮', async () => { + mockApiGet.mockImplementation((url: string) => { + if (url === '/orders/order-001') return Promise.resolve({ data: { ...mockOrder, status: 'PENDING' } }); + return Promise.resolve({ data: { events: [] } }); + }); + render(); + await waitFor(() => { + expect(screen.getByText('开始生产')).toBeInTheDocument(); + }); + }); + + it('COMPLETED 状态不显示流转按钮', async () => { + mockApiGet.mockImplementation((url: string) => { + if (url === '/orders/order-001') return Promise.resolve({ data: { ...mockOrder, status: 'COMPLETED' } }); + return Promise.resolve({ data: { events: [] } }); + }); + render(); + await waitFor(() => { + expect(screen.getByText('已完成')).toBeInTheDocument(); + }); + expect(screen.queryByText('提交订单')).not.toBeInTheDocument(); + expect(screen.queryByText('取消订单')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/login/__tests__/LoginPage.test.tsx b/apps/web/src/app/login/__tests__/LoginPage.test.tsx new file mode 100644 index 0000000..6edcd78 --- /dev/null +++ b/apps/web/src/app/login/__tests__/LoginPage.test.tsx @@ -0,0 +1,151 @@ +/** + * LoginPage.tsx — 冒烟测试 + * + * 覆盖: + * 1. 渲染:标题、默认账号密码、提交按钮 + * 2. 输入:修改邮箱和密码 + * 3. 登录成功:调用 API → setAuth → router.push + * 4. 登录失败:显示错误信息 + * 5. 加载态:按钮显示"正在接入核心..." + */ + +/* ---------- Mock next/navigation ---------- */ +const mockPush = jest.fn(); +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush, back: jest.fn() }), +})); + +/* ---------- Mock API ---------- */ +const mockApiPost = jest.fn(); +jest.mock('../../../lib/api', () => ({ + default: { post: (...a: any[]) => mockApiPost(...a) }, +})); + +/* ---------- Mock authStore ---------- */ +const mockSetAuth = jest.fn(); +jest.mock('../../../store/authStore', () => ({ + useAuthStore: Object.assign( + (selector: any) => selector({ setAuth: mockSetAuth }), + { getState: jest.fn() }, + ), +})); + +/* ---------- Tests ---------- */ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import LoginPage from '../page'; + +describe('LoginPage 冒烟测试', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockApiPost.mockResolvedValue({ + data: { + accessToken: 'jwt-token-abc', + user: { id: 'u1', username: 'admin', role: 'admin' }, + companies: [{ id: 'c1', name: 'TestCo', role: 'owner' }], + }, + }); + }); + + it('渲染登录页标题', () => { + render(); + expect(screen.getByText('智能制造 EIP 全局系统')).toBeInTheDocument(); + }); + + it('默认填充 admin 账号和密码', () => { + render(); + const emailInput = screen.getByPlaceholderText('请输入账号') as HTMLInputElement; + const passwordInput = screen.getByPlaceholderText('••••••••') as HTMLInputElement; + expect(emailInput.value).toBe('admin@erp.com'); + expect(passwordInput.value).toBe('admin'); + }); + + it('提交按钮初始文案为"安全登入"', () => { + render(); + expect(screen.getByText('安全登入')).toBeInTheDocument(); + }); + + it('可以修改邮箱和密码', async () => { + const user = userEvent.setup(); + render(); + const emailInput = screen.getByPlaceholderText('请输入账号'); + const passwordInput = screen.getByPlaceholderText('••••••••'); + + await user.clear(emailInput); + await user.type(emailInput, 'test@example.com'); + await user.clear(passwordInput); + await user.type(passwordInput, 'mypassword'); + + expect(emailInput).toHaveValue('test@example.com'); + expect(passwordInput).toHaveValue('mypassword'); + }); + + it('登录成功后调用 API、setAuth 并跳转 /dashboard', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('安全登入')); + + await waitFor(() => { + expect(mockApiPost).toHaveBeenCalledWith('/auth/login', { + email: 'admin@erp.com', + password: 'admin', + }); + }); + + await waitFor(() => { + expect(mockSetAuth).toHaveBeenCalledWith( + 'jwt-token-abc', + { id: 'u1', username: 'admin', role: 'admin' }, + [{ id: 'c1', name: 'TestCo', role: 'owner' }], + ); + }); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/dashboard'); + }); + }); + + it('登录失败显示错误信息', async () => { + mockApiPost.mockRejectedValueOnce({ + response: { data: { message: '用户名或密码错误' } }, + }); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('安全登入')); + + await waitFor(() => { + expect(screen.getByText('用户名或密码错误')).toBeInTheDocument(); + }); + }); + + it('无服务端 message 时显示默认错误文案', async () => { + mockApiPost.mockRejectedValueOnce(new Error('Network Error')); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('安全登入')); + + await waitFor(() => { + expect(screen.getByText('邮箱或密码错误,或系统未启动')).toBeInTheDocument(); + }); + }); + + it('加载中按钮显示"正在接入核心..."', async () => { + // 让 API 永远不 resolve 来模拟 loading + mockApiPost.mockReturnValue(new Promise(() => {})); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('安全登入')); + + await waitFor(() => { + expect(screen.getByText('正在接入核心...')).toBeInTheDocument(); + }); + }); +}); From 3e33f8c9cfb5730c8f627c485ead39b63abf39cd Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Tue, 5 May 2026 14:57:32 +0800 Subject: [PATCH 07/54] =?UTF-8?q?feat(tax):=20TaxService=20refactor=20?= =?UTF-8?q?=E2=80=94=20TaxCode=20=E6=94=B6=E5=8F=A3=E3=80=81=E9=94=80?= =?UTF-8?q?=E5=94=AE/=E9=87=87=E8=B4=AD=E7=A8=8E=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E3=80=81=E4=BB=B7=E7=A8=8E=E5=BF=AB=E7=85=A7=E3=80=81=E7=A6=81?= =?UTF-8?q?=E6=AD=A2=E8=BF=87=E8=B4=A6=E6=94=B9=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TaxNature enum (OUTPUT/INPUT) for sales vs purchase tax distinction - Create TaxService (core/tax) as single source of truth for tax resolution & calculation - Split TaxCode into outputAccountId / inputAccountId for configurable tax accounts - Add taxRate/taxNature snapshot fields to Invoice; create PurchaseInvoice model - Remove external taxCodeId/taxRate params from postInvoice to freeze historical amounts - Only recalculate for legacy records where subTotal=0 && taxAmount=0 - Add explicit AuditLog fallback warning when no default tax code configured - Add TaxCode CRUD controller (POST/GET/PUT /finance/tax-codes) - Register TaxModule as @Global so any module can inject TaxService - Update metadata schema for taxNature/outputAccountId/inputAccountId + PurchaseInvoice - All 114 tests passing, TypeScript zero errors --- .../migration.sql | 75 +++ apps/api/prisma/schema.prisma | 533 ++++++------------ apps/api/src/app.module.ts | 52 +- .../api/src/core/metadata/metadata.service.ts | 117 +--- apps/api/src/core/tax/tax.module.ts | 14 + apps/api/src/core/tax/tax.service.spec.ts | 192 +++++++ apps/api/src/core/tax/tax.service.ts | 210 +++++++ apps/api/src/finance/accounting.service.ts | 192 ++++--- apps/api/src/finance/dto/tax-code.dto.ts | 109 ++++ .../src/finance/finance.controller.spec.ts | 88 ++- apps/api/src/finance/finance.controller.ts | 71 +-- apps/api/src/finance/finance.module.ts | 21 +- apps/api/src/finance/finance.service.spec.ts | 199 ++++--- apps/api/src/finance/finance.service.ts | 162 +++--- .../src/finance/tax-code.controller.spec.ts | 83 +++ apps/api/src/finance/tax-code.controller.ts | 148 +++++ apps/api/src/orders/orders.service.ts | 117 ++-- run_fix.js | 42 -- run_fix2.js | 13 - run_fix_workflow.js | 190 ------- 20 files changed, 1470 insertions(+), 1158 deletions(-) create mode 100644 apps/api/prisma/migrations/20260505120000_tax_service_refactor/migration.sql create mode 100644 apps/api/src/core/tax/tax.module.ts create mode 100644 apps/api/src/core/tax/tax.service.spec.ts create mode 100644 apps/api/src/core/tax/tax.service.ts create mode 100644 apps/api/src/finance/dto/tax-code.dto.ts create mode 100644 apps/api/src/finance/tax-code.controller.spec.ts create mode 100644 apps/api/src/finance/tax-code.controller.ts delete mode 100644 run_fix.js delete mode 100644 run_fix2.js delete mode 100644 run_fix_workflow.js diff --git a/apps/api/prisma/migrations/20260505120000_tax_service_refactor/migration.sql b/apps/api/prisma/migrations/20260505120000_tax_service_refactor/migration.sql new file mode 100644 index 0000000..39a8c3e --- /dev/null +++ b/apps/api/prisma/migrations/20260505120000_tax_service_refactor/migration.sql @@ -0,0 +1,75 @@ +-- TaxService refactor: TaxNature enum, split tax accounts, tax snapshots, PurchaseInvoice. +-- This migration is additive and backward-compatible. + +-- 1. TaxNature enum +DO $$ BEGIN + CREATE TYPE "TaxNature" AS ENUM ('OUTPUT', 'INPUT'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +-- 2. TaxCode: add taxNature, outputAccountId, inputAccountId +ALTER TABLE "TaxCode" ADD COLUMN IF NOT EXISTS "taxNature" "TaxNature" NOT NULL DEFAULT 'OUTPUT'; +ALTER TABLE "TaxCode" ADD COLUMN IF NOT EXISTS "outputAccountId" TEXT; +ALTER TABLE "TaxCode" ADD COLUMN IF NOT EXISTS "inputAccountId" TEXT; + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'TaxCode_outputAccountId_fkey') THEN + ALTER TABLE "TaxCode" ADD CONSTRAINT "TaxCode_outputAccountId_fkey" + FOREIGN KEY ("outputAccountId") REFERENCES "Account"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'TaxCode_inputAccountId_fkey') THEN + ALTER TABLE "TaxCode" ADD CONSTRAINT "TaxCode_inputAccountId_fkey" + FOREIGN KEY ("inputAccountId") REFERENCES "Account"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS "TaxCode_outputAccountId_idx" ON "TaxCode"("outputAccountId"); +CREATE INDEX IF NOT EXISTS "TaxCode_inputAccountId_idx" ON "TaxCode"("inputAccountId"); + +-- Backfill: copy existing accountId to outputAccountId for OUTPUT tax codes, inputAccountId for INPUT +UPDATE "TaxCode" SET "outputAccountId" = "accountId" WHERE "accountId" IS NOT NULL AND "taxNature" = 'OUTPUT'; +UPDATE "TaxCode" SET "inputAccountId" = "accountId" WHERE "accountId" IS NOT NULL AND "taxNature" = 'INPUT'; + +-- 3. Invoice: add taxRate, taxNature snapshot columns +ALTER TABLE "Invoice" ADD COLUMN IF NOT EXISTS "taxRate" DOUBLE PRECISION NOT NULL DEFAULT 0; +ALTER TABLE "Invoice" ADD COLUMN IF NOT EXISTS "taxNature" "TaxNature" NOT NULL DEFAULT 'OUTPUT'; + +-- 4. PurchaseInvoice table +CREATE TABLE IF NOT EXISTS "PurchaseInvoice" ( + "id" TEXT NOT NULL, + "invoiceNo" TEXT NOT NULL, + "partnerId" TEXT NOT NULL, + "amount" DOUBLE PRECISION NOT NULL, + "subTotal" DOUBLE PRECISION NOT NULL DEFAULT 0, + "taxAmount" DOUBLE PRECISION NOT NULL DEFAULT 0, + "taxRate" DOUBLE PRECISION NOT NULL DEFAULT 0, + "taxCodeId" TEXT, + "taxNature" "TaxNature" NOT NULL DEFAULT 'INPUT', + "status" TEXT NOT NULL, + "postingStatus" "EntryPostingStatus" NOT NULL DEFAULT 'DRAFT', + "companyId" TEXT NOT NULL, + "dueDate" TIMESTAMP(3), + "issuedDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PurchaseInvoice_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "PurchaseInvoice_invoiceNo_key" ON "PurchaseInvoice"("invoiceNo"); +CREATE INDEX IF NOT EXISTS "PurchaseInvoice_companyId_status_idx" ON "PurchaseInvoice"("companyId", "status"); +CREATE INDEX IF NOT EXISTS "PurchaseInvoice_partnerId_idx" ON "PurchaseInvoice"("partnerId"); + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PurchaseInvoice_partnerId_fkey') THEN + ALTER TABLE "PurchaseInvoice" ADD CONSTRAINT "PurchaseInvoice_partnerId_fkey" + FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PurchaseInvoice_companyId_fkey') THEN + ALTER TABLE "PurchaseInvoice" ADD CONSTRAINT "PurchaseInvoice_companyId_fkey" + FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PurchaseInvoice_taxCodeId_fkey') THEN + ALTER TABLE "PurchaseInvoice" ADD CONSTRAINT "PurchaseInvoice_taxCodeId_fkey" + FOREIGN KEY ("taxCodeId") REFERENCES "TaxCode"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 0eb88f0..76d371d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -26,28 +26,26 @@ model Company { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - users UserCompanyRole[] - departments Department[] - orders Order[] - warehouses Warehouse[] - locations StockLocation[] - materials Material[] - partners Partner[] - products Product[] - categories ProductCategory[] - boms Bom[] - fileRecords FileRecord[] - workflows Workflow[] - customFields CustomFieldDefinition[] - auditLogs AuditLog[] - accounts Account[] - journals Journal[] - journalEntries JournalEntry[] + users UserCompanyRole[] + departments Department[] + orders Order[] + warehouses Warehouse[] + locations StockLocation[] + materials Material[] + partners Partner[] + products Product[] + categories ProductCategory[] + boms Bom[] + fileRecords FileRecord[] + workflows Workflow[] + customFields CustomFieldDefinition[] + auditLogs AuditLog[] + accounts Account[] + journals Journal[] + journalEntries JournalEntry[] eventDlqRecords EventDlq[] - taxCodes TaxCode[] - purchaseOrders PurchaseOrder[] - goodsReceipts GoodsReceipt[] - stockPickings StockPicking[] + taxCodes TaxCode[] + purchaseInvoices PurchaseInvoice[] } enum PartnerType { @@ -83,49 +81,10 @@ enum EntryPostingStatus { CANCELLED } -// ========================================== -// Purchase Module Enums -// ========================================== - -enum PurchaseOrderStatus { - DRAFT - SUBMITTED - APPROVED - ORDERED - PARTIALLY_RECEIVED - RECEIVED - CANCELLED -} -enum GoodsReceiptStatus { - DRAFT - CONFIRMED - CANCELLED -} - -// ========================================== -// Stock Execution Enums -// ========================================== - -enum PickingType { - INBOUND - OUTBOUND - INTERNAL -} - -enum PickingStatus { - DRAFT - CONFIRMED - DONE - CANCELLED -} - -enum MoveStatus { - DRAFT - WAITING - CONFIRMED - DONE - CANCELLED +enum TaxNature { + OUTPUT // 销项税 + INPUT // 进项税 } model User { @@ -175,26 +134,25 @@ model Department { } model Partner { - id String @id @default(uuid()) - code String? - name String - type PartnerType @default(CUSTOMER) - contact String? - phone String? - email String? - taxId String? - address String? + id String @id @default(uuid()) + code String? + name String + type PartnerType @default(CUSTOMER) + contact String? + phone String? + email String? + taxId String? + address String? customAttributes Json? - isActive Boolean @default(true) - companyId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - company Company @relation(fields: [companyId], references: [id]) - orders Order[] - purchaseOrders PurchaseOrder[] - goodsReceipts GoodsReceipt[] + isActive Boolean @default(true) + companyId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + company Company @relation(fields: [companyId], references: [id]) + orders Order[] journalEntryLines JournalEntryLine[] + purchaseInvoices PurchaseInvoice[] @@unique([companyId, code]) @@index([companyId, type]) @@ -208,16 +166,22 @@ model TaxCode { isTaxInclusive Boolean @default(true) isDefault Boolean @default(false) active Boolean @default(true) + taxNature TaxNature @default(OUTPUT) accountId String? + outputAccountId String? + inputAccountId String? companyId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - company Company @relation(fields: [companyId], references: [id]) - account Account? @relation(fields: [accountId], references: [id]) - orders Order[] + company Company @relation(fields: [companyId], references: [id]) + account Account? @relation(fields: [accountId], references: [id]) + outputAccount Account? @relation("OutputTaxAccount", fields: [outputAccountId], references: [id]) + inputAccount Account? @relation("InputTaxAccount", fields: [inputAccountId], references: [id]) + orders Order[] orderItems OrderItem[] - invoices Invoice[] + invoices Invoice[] + purchaseInvoices PurchaseInvoice[] @@unique([companyId, code]) @@index([companyId, active]) @@ -229,20 +193,20 @@ model TaxCode { // ========================================== model Order { - id String @id @default(uuid()) - orderNo String @unique - partnerId String - salesId String // 挂单销售员 - companyId String - status String // "DRAFT", "PENDING", "IN_PRODUCTION", "SHIPPED", "COMPLETED" - totalAmount Float @default(0) - subTotal Float @default(0) - taxTotal Float @default(0) - taxCodeId String? - aiSummary Json? // 存放 AI 抓取的客户需求 JSON 结构 + id String @id @default(uuid()) + orderNo String @unique + partnerId String + salesId String // 挂单销售员 + companyId String + status String // "DRAFT", "PENDING", "IN_PRODUCTION", "SHIPPED", "COMPLETED" + totalAmount Float @default(0) + subTotal Float @default(0) + taxTotal Float @default(0) + taxCodeId String? + aiSummary Json? // 存放 AI 抓取的客户需求 JSON 结构 customAttributes Json? - expectedDate DateTime? // 预计交付日期 - notes String? // 备注 + expectedDate DateTime? // 预计交付日期 + notes String? // 备注 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -257,79 +221,22 @@ model Order { } model OrderItem { - id String @id @default(uuid()) - orderId String - productId String // 关联的产品/模型名称 - quantity Int - unitPrice Float - totalPrice Float - subTotal Float @default(0) - taxAmount Float @default(0) - taxRate Float @default(0) - taxCodeId String? + id String @id @default(uuid()) + orderId String + productId String // 关联的产品/模型名称 + quantity Int + unitPrice Float + totalPrice Float + subTotal Float @default(0) + taxAmount Float @default(0) + taxRate Float @default(0) + taxCodeId String? customAttributes Json? order Order @relation(fields: [orderId], references: [id]) taxCode TaxCode? @relation(fields: [taxCodeId], references: [id]) } -// ========================================== -// Purchase Management -// ========================================== - -model PurchaseOrder { - id String @id @default(uuid()) - orderNo String @unique - partnerId String - orderDate DateTime @default(now()) - expectedDate DateTime? - status PurchaseOrderStatus @default(DRAFT) - currency String @default("CNY") - subTotal Float @default(0) - taxTotal Float @default(0) - totalAmount Float @default(0) - notes String? - companyId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - partner Partner @relation(fields: [partnerId], references: [id]) - company Company @relation(fields: [companyId], references: [id]) - lines PurchaseOrderLine[] - goodsReceipts GoodsReceipt[] - - @@index([companyId, partnerId]) - @@index([companyId, status]) -} - -model PurchaseOrderLine { - id String @id @default(uuid()) - orderId String - lineNo Int - materialId String? - productId String? - description String? - quantity Float - receivedQuantity Float @default(0) - unitPrice Float - taxRate Float @default(0) - taxAmount Float @default(0) - subTotal Float @default(0) - totalAmount Float @default(0) - companyId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - order PurchaseOrder @relation(fields: [orderId], references: [id]) - material Material? @relation(fields: [materialId], references: [id]) - product Product? @relation(fields: [productId], references: [id]) - goodsReceiptLines GoodsReceiptLine[] - - @@unique([orderId, lineNo]) - @@index([materialId]) - @@index([productId]) -} - // ========================================== // 智能仓储与物料管理 // ========================================== @@ -340,8 +247,8 @@ model Warehouse { type String // "MATERIAL", "FINISHED", "PART" companyId String - company Company @relation(fields: [companyId], references: [id]) - locations StockLocation[] + company Company @relation(fields: [companyId], references: [id]) + locations StockLocation[] } model StockLocation { @@ -352,7 +259,7 @@ model StockLocation { isActive Boolean @default(true) companyId String warehouseId String? - parentId String? // 自引用父库位,支持 华南仓/货架区/A01层 层次结构 + parentId String? // 自引用父库位,支持 华南仓/货架区/A01层 层次结构 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -363,8 +270,6 @@ model StockLocation { quants StockQuant[] outgoingTransactions InventoryTransaction[] @relation("StockMoveSourceLocation") incomingTransactions InventoryTransaction[] @relation("StockMoveDestinationLocation") - sourceMoves StockMove[] @relation("StockMoveSourceLocation") - destMoves StockMove[] @relation("StockMoveDestLocation") @@unique([companyId, code]) @@index([companyId, warehouseId]) @@ -387,9 +292,6 @@ model Material { inventoryTransactions InventoryTransaction[] products Product[] bomLines BomLine[] - purchaseOrderLines PurchaseOrderLine[] - goodsReceiptLines GoodsReceiptLine[] - stockMoves StockMove[] } model ProductCategory { @@ -411,26 +313,24 @@ model ProductCategory { } model Product { - id String @id @default(uuid()) - sku String - name String - type ProductType @default(STOCKABLE) - categoryId String? - materialId String? - uom String @default("pcs") - description String? + id String @id @default(uuid()) + sku String + name String + type ProductType @default(STOCKABLE) + categoryId String? + materialId String? + uom String @default("pcs") + description String? customAttributes Json? - isActive Boolean @default(true) - companyId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - company Company @relation(fields: [companyId], references: [id]) - category ProductCategory? @relation(fields: [categoryId], references: [id]) - material Material? @relation(fields: [materialId], references: [id]) - boms Bom[] - PurchaseOrderLine PurchaseOrderLine[] - GoodsReceiptLine GoodsReceiptLine[] + isActive Boolean @default(true) + companyId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + company Company @relation(fields: [companyId], references: [id]) + category ProductCategory? @relation(fields: [categoryId], references: [id]) + material Material? @relation(fields: [materialId], references: [id]) + boms Bom[] @@unique([companyId, sku]) @@index([companyId, categoryId]) @@ -455,19 +355,19 @@ model Bom { } model CustomFieldDefinition { - id String @id @default(uuid()) - modelName String - fieldName String - label String - type CustomFieldType - required Boolean @default(false) - referenceModel String? - referenceLabelField String? - referenceValueField String? + id String @id @default(uuid()) + modelName String + fieldName String + label String + type CustomFieldType + required Boolean @default(false) + referenceModel String? + referenceLabelField String? + referenceValueField String? referenceRelationField String? - companyId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + companyId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt company Company @relation(fields: [companyId], references: [id]) @@ -491,12 +391,11 @@ model BomLine { } model StockQuant { - id String @id @default(uuid()) - locationId String - materialId String - batchNo String - quantity Float - reservedQuantity Float @default(0) + id String @id @default(uuid()) + locationId String + materialId String + batchNo String + quantity Float createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -508,111 +407,6 @@ model StockQuant { @@index([materialId]) } -// ========================================== -// Goods Receipt -// ========================================== - -model GoodsReceipt { - id String @id @default(uuid()) - receiptNo String @unique - purchaseOrderId String? - partnerId String? - receiptDate DateTime @default(now()) - status GoodsReceiptStatus @default(DRAFT) - notes String? - companyId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id]) - partner Partner? @relation(fields: [partnerId], references: [id]) - company Company @relation(fields: [companyId], references: [id]) - lines GoodsReceiptLine[] - - @@index([companyId, purchaseOrderId]) - @@index([companyId, status]) -} - -model GoodsReceiptLine { - id String @id @default(uuid()) - receiptId String - lineNo Int - purchaseOrderLineId String? - materialId String? - productId String? - quantity Float - batchNo String? - destLocationId String? - companyId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - receipt GoodsReceipt @relation(fields: [receiptId], references: [id]) - purchaseOrderLine PurchaseOrderLine? @relation(fields: [purchaseOrderLineId], references: [id]) - material Material? @relation(fields: [materialId], references: [id]) - product Product? @relation(fields: [productId], references: [id]) - - @@unique([receiptId, lineNo]) - @@index([purchaseOrderLineId]) - @@index([materialId]) -} - -// ========================================== -// Stock Picking & Move -// ========================================== - -model StockPicking { - id String @id @default(uuid()) - pickingNo String @unique - type PickingType - referenceType String? - referenceId String? - scheduledDate DateTime? - completedDate DateTime? - status PickingStatus @default(DRAFT) - notes String? - companyId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - company Company @relation(fields: [companyId], references: [id]) - moves StockMove[] - - @@index([companyId, status]) - @@index([companyId, type]) - @@index([referenceType, referenceId]) -} - -model StockMove { - id String @id @default(uuid()) - pickingId String - lineNo Int - materialId String - sourceLocationId String? - destLocationId String? - quantity Float - quantityDone Float @default(0) - batchNo String? - unitCost Float @default(0) - totalCost Float @default(0) - status MoveStatus @default(DRAFT) - companyId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - picking StockPicking @relation(fields: [pickingId], references: [id]) - material Material @relation(fields: [materialId], references: [id]) - sourceLocation StockLocation? @relation("StockMoveSourceLocation", fields: [sourceLocationId], references: [id]) - destLocation StockLocation? @relation("StockMoveDestLocation", fields: [destLocationId], references: [id]) - transactions InventoryTransaction[] - - @@unique([pickingId, lineNo]) - @@index([materialId]) - @@index([sourceLocationId]) - @@index([destLocationId]) - @@index([companyId, status]) -} - // ========================================== // 附加模块: 文件、流转、制造与财务 // ========================================== @@ -632,29 +426,26 @@ model FileRecord { } model InventoryTransaction { - id String @id @default(uuid()) - type String // "INBOUND", "OUTBOUND", "TRANSFER" - materialId String - sourceLocationId String? - destLocationId String? - quantity Float - batchNo String? - operatorId String - companyId String - referenceNo String? // 关联单号(订单号/采购号等) - stockMoveId String? // 关联库存执行单据行 - note String? - createdAt DateTime @default(now()) - - material Material @relation(fields: [materialId], references: [id]) - sourceLocation StockLocation? @relation("StockMoveSourceLocation", fields: [sourceLocationId], references: [id]) - destLocation StockLocation? @relation("StockMoveDestinationLocation", fields: [destLocationId], references: [id]) - stockMove StockMove? @relation(fields: [stockMoveId], references: [id]) + id String @id @default(uuid()) + type String // "INBOUND", "OUTBOUND", "TRANSFER" + materialId String + sourceLocationId String? + destLocationId String? + quantity Float + batchNo String? + operatorId String + companyId String + referenceNo String? // 关联单号(订单号/采购号等) + note String? + createdAt DateTime @default(now()) + + material Material @relation(fields: [materialId], references: [id]) + sourceLocation StockLocation? @relation("StockMoveSourceLocation", fields: [sourceLocationId], references: [id]) + destLocation StockLocation? @relation("StockMoveDestinationLocation", fields: [destLocationId], references: [id]) @@index([companyId, materialId]) @@index([sourceLocationId]) @@index([destLocationId]) - @@index([stockMoveId]) } model Workflow { @@ -667,7 +458,7 @@ model Workflow { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - company Company? @relation(fields: [companyId], references: [id]) + company Company? @relation(fields: [companyId], references: [id]) states WorkflowState[] transitions WorkflowTransition[] @@ -741,18 +532,20 @@ model WorkReport { } model Invoice { - id String @id @default(uuid()) - invoiceNo String @unique - orderId String - amount Float - subTotal Float @default(0) - taxAmount Float @default(0) - taxCodeId String? - status String // "UNPAID", "PARTIAL", "PAID" + id String @id @default(uuid()) + invoiceNo String @unique + orderId String + amount Float + subTotal Float @default(0) + taxAmount Float @default(0) + taxCodeId String? + taxRate Float @default(0) + taxNature TaxNature @default(OUTPUT) + status String // "UNPAID", "PARTIAL", "PAID" postingStatus EntryPostingStatus @default(DRAFT) - companyId String - dueDate DateTime? - issuedDate DateTime @default(now()) + companyId String + dueDate DateTime? + issuedDate DateTime @default(now()) order Order @relation(fields: [orderId], references: [id]) taxCode TaxCode? @relation(fields: [taxCodeId], references: [id]) @@ -769,6 +562,30 @@ model Payment { invoice Invoice @relation(fields: [invoiceId], references: [id]) } +model PurchaseInvoice { + id String @id @default(uuid()) + invoiceNo String @unique + partnerId String + amount Float + subTotal Float @default(0) + taxAmount Float @default(0) + taxRate Float @default(0) + taxCodeId String? + taxNature TaxNature @default(INPUT) + status String // "UNPAID", "PARTIAL", "PAID" + postingStatus EntryPostingStatus @default(DRAFT) + companyId String + dueDate DateTime? + issuedDate DateTime @default(now()) + + partner Partner @relation(fields: [partnerId], references: [id]) + company Company @relation(fields: [companyId], references: [id]) + taxCode TaxCode? @relation(fields: [taxCodeId], references: [id]) + + @@index([companyId, status]) + @@index([partnerId]) +} + model Account { id String @id @default(uuid()) code String @@ -780,11 +597,13 @@ model Account { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - parent Account? @relation("AccountTree", fields: [parentId], references: [id]) - children Account[] @relation("AccountTree") - company Company @relation(fields: [companyId], references: [id]) + parent Account? @relation("AccountTree", fields: [parentId], references: [id]) + children Account[] @relation("AccountTree") + company Company @relation(fields: [companyId], references: [id]) lines JournalEntryLine[] - taxCodes TaxCode[] + taxCodes TaxCode[] + outputTaxCodes TaxCode[] @relation("OutputTaxAccount") + inputTaxCodes TaxCode[] @relation("InputTaxAccount") @@unique([companyId, code]) @@index([companyId, parentId]) @@ -851,18 +670,18 @@ model JournalEntryLine { } model EventDlq { - id String @id @default(uuid()) + id String @id @default(uuid()) eventName String idempotencyKey String? payload Json error String - attempts Int @default(1) - maxAttempts Int @default(5) + attempts Int @default(1) + maxAttempts Int @default(5) nextRetryAt DateTime? - status String @default("PENDING") + status String @default("PENDING") companyId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt company Company? @relation(fields: [companyId], references: [id]) @@ -883,4 +702,4 @@ model AuditLog { user User @relation(fields: [userId], references: [id]) company Company @relation(fields: [companyId], references: [id]) -} +} \ No newline at end of file diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index d820294..da8283b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,26 +1,27 @@ -import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; -import { EventEmitterModule } from '@nestjs/event-emitter'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { PrismaModule } from './prisma/prisma.module'; -import { AuthModule } from './auth/auth.module'; -import { UsersModule } from './users/users.module'; -import { OrdersModule } from './orders/orders.module'; -import { FilesModule } from './files/files.module'; -import { InventoryModule } from './inventory/inventory.module'; -import { DashboardModule } from './dashboard/dashboard.module'; -import { ProductionModule } from './production/production.module'; -import { FinanceModule } from './finance/finance.module'; -import { LoggerMiddleware } from './core/middlewares/logger.middleware'; -import { DepartmentsModule } from './departments/departments.module'; -import { AppCacheModule } from './core/cache/cache.module'; -import { CrudModule } from './core/crud/crud.module'; -import { MetadataModule } from './core/metadata/metadata.module'; -import { WorkflowModule } from './core/workflow/workflow.module'; -import { AIModule } from './core/ai/ai.module'; -import { AuditModule } from './core/audit/audit.module'; -import { TenantContextMiddleware } from './core/middlewares/tenant-context.middleware'; -import { KyselyModule } from './core/prisma/kysely.module'; +import { Module, MiddlewareConsumer, NestModule } from "@nestjs/common"; +import { EventEmitterModule } from "@nestjs/event-emitter"; +import { AppController } from "./app.controller"; +import { AppService } from "./app.service"; +import { PrismaModule } from "./prisma/prisma.module"; +import { AuthModule } from "./auth/auth.module"; +import { UsersModule } from "./users/users.module"; +import { OrdersModule } from "./orders/orders.module"; +import { FilesModule } from "./files/files.module"; +import { InventoryModule } from "./inventory/inventory.module"; +import { DashboardModule } from "./dashboard/dashboard.module"; +import { ProductionModule } from "./production/production.module"; +import { FinanceModule } from "./finance/finance.module"; +import { LoggerMiddleware } from "./core/middlewares/logger.middleware"; +import { DepartmentsModule } from "./departments/departments.module"; +import { AppCacheModule } from "./core/cache/cache.module"; +import { CrudModule } from "./core/crud/crud.module"; +import { MetadataModule } from "./core/metadata/metadata.module"; +import { WorkflowModule } from "./core/workflow/workflow.module"; +import { AIModule } from "./core/ai/ai.module"; +import { AuditModule } from "./core/audit/audit.module"; +import { TenantContextMiddleware } from "./core/middlewares/tenant-context.middleware"; +import { KyselyModule } from "./core/prisma/kysely.module"; +import { TaxModule } from "./core/tax/tax.module"; @Module({ imports: [ @@ -28,6 +29,7 @@ import { KyselyModule } from './core/prisma/kysely.module'; AppCacheModule, KyselyModule, PrismaModule, + TaxModule, MetadataModule, WorkflowModule, AuditModule, @@ -48,6 +50,6 @@ import { KyselyModule } from './core/prisma/kysely.module'; }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { - consumer.apply(LoggerMiddleware, TenantContextMiddleware).forRoutes('*'); + consumer.apply(LoggerMiddleware, TenantContextMiddleware).forRoutes("*"); } -} +} \ No newline at end of file diff --git a/apps/api/src/core/metadata/metadata.service.ts b/apps/api/src/core/metadata/metadata.service.ts index ad3043e..e770138 100644 --- a/apps/api/src/core/metadata/metadata.service.ts +++ b/apps/api/src/core/metadata/metadata.service.ts @@ -607,124 +607,41 @@ export class MetadataService { }, ], [ - 'purchaseOrder', + 'purchaseInvoice', { - model: 'purchaseOrder', - label: '采购订单', - description: '采购订单主数据,管理供应商采购业务。', + model: 'purchaseInvoice', + label: '采购发票', + description: '供应商发票与付款管理。', companyScoped: true, fields: [ - { name: 'orderNo', label: '采购单号', type: 'string', required: true }, - { - name: 'partnerId', - label: '供应商', - type: 'reference', - required: true, - reference: { - model: 'partner', - labelField: 'name', - valueField: 'id', - relationField: 'partner', - }, - }, - { - name: 'taxCodeId', - label: '税码', - type: 'reference', - reference: { - model: 'taxCode', - labelField: 'name', - valueField: 'id', - relationField: 'taxCode', - }, - }, + { name: 'invoiceNo', label: '发票号', type: 'string' }, + { name: 'partnerId', label: '供应商', type: 'reference', reference: { model: 'partner', labelField: 'name', valueField: 'id', relationField: 'partner' } }, + { name: 'taxCodeId', label: '税码', type: 'reference', reference: { model: 'taxCode', labelField: 'name', valueField: 'id', relationField: 'taxCode' } }, + { name: 'taxNature', label: '税务属性', type: 'select', options: [{ label: '进项税', value: 'INPUT' }, { label: '销项税', value: 'OUTPUT' }] }, { name: 'status', label: '状态', type: 'string' }, { name: 'subTotal', label: '未税金额', type: 'number' }, - { name: 'taxTotal', label: '税额', type: 'number' }, - { name: 'totalAmount', label: '总金额', type: 'number' }, - { name: 'orderDate', label: '下单日期', type: 'date' }, - { name: 'expectedDate', label: '预计到货', type: 'date' }, - { name: 'notes', label: '备注', type: 'text' }, + { name: 'taxAmount', label: '税额', type: 'number' }, + { name: 'amount', label: '金额', type: 'number' }, + { name: 'dueDate', label: '到期日', type: 'date' }, ], views: { - form: { - sections: [ - { title: '基础信息', fields: ['orderNo', 'partnerId', 'taxCodeId', 'status', 'orderDate', 'expectedDate'] }, - { title: '金额信息', fields: ['subTotal', 'taxTotal', 'totalAmount'] }, - { title: '备注', fields: ['notes'] }, - ], - }, + form: { fields: ['invoiceNo', 'partnerId', 'taxCodeId', 'taxNature', 'status', 'amount', 'dueDate'] }, list: { - columns: ['orderNo', 'partnerId', 'status', 'subTotal', 'taxTotal', 'totalAmount', 'orderDate', 'expectedDate'], + columns: ['invoiceNo', 'partnerId', 'taxCodeId', 'taxNature', 'status', 'subTotal', 'taxAmount', 'amount', 'dueDate'], defaultSort: { createdAt: 'desc' }, - searchFields: ['orderNo', 'status'], + searchFields: ['invoiceNo', 'status'], }, kanban: { statusField: 'status', columns: [ - { value: 'DRAFT', label: '草稿', color: 'bg-slate-50' }, - { value: 'PENDING', label: '待审批', color: 'bg-amber-50' }, - { value: 'APPROVED', label: '已批准', color: 'bg-sky-50' }, - { value: 'ORDERED', label: '已下单', color: 'bg-indigo-50' }, - { value: 'RECEIVED', label: '已收货', color: 'bg-emerald-50' }, - { value: 'CANCELLED', label: '已取消', color: 'bg-rose-50' }, + { value: 'UNPAID', label: '未付款', color: 'bg-rose-50' }, + { value: 'PARTIAL', label: '部分付款', color: 'bg-amber-50' }, + { value: 'PAID', label: '已付款', color: 'bg-emerald-50' }, ], }, }, }, ], - [ - 'purchaseOrderLine', - { - model: 'purchaseOrderLine', - label: '采购订单行', - description: '采购订单明细行项目。', - companyScoped: true, - fields: [ - { - name: 'purchaseOrderId', - label: '采购订单', - type: 'reference', - required: true, - reference: { - model: 'purchaseOrder', - labelField: 'orderNo', - valueField: 'id', - relationField: 'purchaseOrder', - }, - }, - { - name: 'materialId', - label: '物料', - type: 'reference', - required: true, - reference: { - model: 'material', - labelField: 'name', - valueField: 'id', - relationField: 'material', - }, - }, - { name: 'quantity', label: '数量', type: 'number', required: true }, - { name: 'unitPrice', label: '单价', type: 'number', required: true }, - { name: 'subTotal', label: '未税金额', type: 'number' }, - { name: 'taxAmount', label: '税额', type: 'number' }, - { name: 'totalPrice', label: '含税金额', type: 'number' }, - { name: 'receivedQty', label: '已收货数量', type: 'number' }, - { name: 'notes', label: '备注', type: 'text' }, - ], - views: { - form: { - fields: ['purchaseOrderId', 'materialId', 'quantity', 'unitPrice', 'subTotal', 'taxAmount', 'totalPrice', 'receivedQty', 'notes'], - }, - list: { - columns: ['purchaseOrderId', 'materialId', 'quantity', 'unitPrice', 'subTotal', 'taxAmount', 'totalPrice', 'receivedQty'], - defaultSort: { createdAt: 'desc' }, - searchFields: ['purchaseOrderId', 'materialId'], - }, - }, - }, - ], ]); async listSchemas(companyId?: string) { diff --git a/apps/api/src/core/tax/tax.module.ts b/apps/api/src/core/tax/tax.module.ts new file mode 100644 index 0000000..99a6f4a --- /dev/null +++ b/apps/api/src/core/tax/tax.module.ts @@ -0,0 +1,14 @@ +import { Module, Global } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { TaxService } from './tax.service'; + +/** + * 税码服务全局模块 —— 任何需要计算税率的模块只需注入 TaxService。 + */ +@Global() +@Module({ + imports: [PrismaModule], + providers: [TaxService], + exports: [TaxService], +}) +export class TaxModule {} diff --git a/apps/api/src/core/tax/tax.service.spec.ts b/apps/api/src/core/tax/tax.service.spec.ts new file mode 100644 index 0000000..b6a7529 --- /dev/null +++ b/apps/api/src/core/tax/tax.service.spec.ts @@ -0,0 +1,192 @@ +import { TaxNature } from '@prisma/client'; +import { TaxService } from './tax.service'; + +type MockPrisma = { + taxCode: { findFirst: jest.Mock }; + auditLog: { create: jest.Mock }; +}; + +describe('TaxService', () => { + const prisma: MockPrisma = { + taxCode: { findFirst: jest.fn() }, + auditLog: { create: jest.fn() }, + }; + + let service: TaxService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new TaxService( + prisma as unknown as ConstructorParameters[0], + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('round2', () => { + it('should round to 2 decimal places', () => { + expect(service.round2(1.005)).toBe(1.01); + expect(service.round2(0)).toBe(0); + expect(service.round2(123.456)).toBe(123.46); + }); + }); + + describe('calcTaxFromTotal', () => { + it('should calculate subTotal and tax from inclusive total', () => { + const result = service.calcTaxFromTotal(113, 0.13); + expect(result.subTotal).toBe(100); + expect(result.taxAmount).toBe(13); + expect(result.total).toBe(113); + expect(result.taxRate).toBe(0.13); + }); + + it('should handle zero taxRate', () => { + const result = service.calcTaxFromTotal(100, 0); + expect(result.subTotal).toBe(100); + expect(result.taxAmount).toBe(0); + }); + + it('should clamp taxRate to [0,1]', () => { + const result = service.calcTaxFromTotal(100, 2); + expect(result.taxRate).toBe(1); + }); + }); + + describe('calcTaxBreakdown', () => { + it('should calculate inclusive tax', () => { + const result = service.calcTaxBreakdown(113, 0.13, true); + expect(result.subTotal).toBe(100); + expect(result.taxAmount).toBe(13); + expect(result.total).toBe(113); + }); + + it('should calculate exclusive tax', () => { + const result = service.calcTaxBreakdown(100, 0.13, false); + expect(result.subTotal).toBe(100); + expect(result.taxAmount).toBe(13); + expect(result.total).toBe(113); + }); + }); + + describe('resolveTaxCode', () => { + it('should return explicit tax code when found', async () => { + prisma.taxCode.findFirst.mockResolvedValue({ + id: 'tc1', + code: 'VAT_13', + name: '增值税13%', + rate: 0.13, + isTaxInclusive: true, + taxNature: TaxNature.OUTPUT, + outputAccountId: 'acc1', + inputAccountId: null, + accountId: 'acc1', + }); + + const result = await service.resolveTaxCode('c1', 'tc1'); + expect(result.id).toBe('tc1'); + expect(result.isFallback).toBe(false); + expect(result.rate).toBe(0.13); + }); + + it('should throw when explicit taxCodeId not found', async () => { + prisma.taxCode.findFirst.mockResolvedValue(null); + await expect( + service.resolveTaxCode('c1', 'nonexistent'), + ).rejects.toThrow('税码不存在或已停用'); + }); + + it('should return default tax code when no explicit id', async () => { + prisma.taxCode.findFirst.mockResolvedValue({ + id: 'tc-default', + code: 'VAT_13', + name: '增值税13%', + rate: 0.13, + isTaxInclusive: true, + taxNature: TaxNature.OUTPUT, + outputAccountId: null, + inputAccountId: null, + accountId: null, + }); + + const result = await service.resolveTaxCode('c1'); + expect(result.id).toBe('tc-default'); + expect(result.isFallback).toBe(false); + }); + + it('should fallback to 13% and audit when no default configured', async () => { + prisma.taxCode.findFirst.mockResolvedValue(null); + prisma.auditLog.create.mockResolvedValue({}); + + const result = await service.resolveTaxCode('c1', null, { + operatorId: 'u1', + entity: 'Order', + entityId: 'o1', + }); + + expect(result.id).toBeNull(); + expect(result.rate).toBe(0.13); + expect(result.isFallback).toBe(true); + expect(prisma.auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: 'TAX_FALLBACK', + entity: 'Order', + entityId: 'o1', + }), + }), + ); + }); + }); + + describe('getTaxAccountId', () => { + it('should return outputAccountId for OUTPUT nature', () => { + const resolved = { + id: 'tc1', + code: '', + name: '', + rate: 0.13, + isTaxInclusive: true, + taxNature: TaxNature.OUTPUT, + outputAccountId: 'out-acc', + inputAccountId: 'in-acc', + accountId: 'legacy-acc', + isFallback: false, + }; + expect(service.getTaxAccountId(resolved)).toBe('out-acc'); + }); + + it('should return inputAccountId for INPUT nature', () => { + const resolved = { + id: 'tc1', + code: '', + name: '', + rate: 0.13, + isTaxInclusive: true, + taxNature: TaxNature.INPUT, + outputAccountId: 'out-acc', + inputAccountId: 'in-acc', + accountId: 'legacy-acc', + isFallback: false, + }; + expect(service.getTaxAccountId(resolved, TaxNature.INPUT)).toBe('in-acc'); + }); + + it('should fallback to accountId when specific field is null', () => { + const resolved = { + id: 'tc1', + code: '', + name: '', + rate: 0.13, + isTaxInclusive: true, + taxNature: TaxNature.OUTPUT, + outputAccountId: null, + inputAccountId: null, + accountId: 'legacy-acc', + isFallback: false, + }; + expect(service.getTaxAccountId(resolved)).toBe('legacy-acc'); + }); + }); +}); diff --git a/apps/api/src/core/tax/tax.service.ts b/apps/api/src/core/tax/tax.service.ts new file mode 100644 index 0000000..0cc198e --- /dev/null +++ b/apps/api/src/core/tax/tax.service.ts @@ -0,0 +1,210 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { TaxNature, Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; + +/** 税码解析结果,附带 fallback 标记 */ +export interface ResolvedTaxCode { + id: string | null; + code: string; + name: string; + rate: number; + isTaxInclusive: boolean; + taxNature: TaxNature; + outputAccountId: string | null; + inputAccountId: string | null; + /** legacy 兼容字段 */ + accountId: string | null; + isFallback: boolean; +} + +/** 含税/未税计算结果 */ +export interface TaxBreakdown { + subTotal: number; + taxAmount: number; + total: number; + taxRate: number; + taxNature: TaxNature; +} + +/** + * 统一税码服务 —— 唯一的税率解析 & 价税计算入口。 + * + * 设计原则: + * 1. 所有模块(Order / Invoice / PurchaseInvoice)调用此服务解析税码和计算税额。 + * 2. fallback 到 13% 硬编码税率时写入 AuditLog 并返回 isFallback=true。 + * 3. 过账(posting)时禁止重新按当前税率覆盖历史快照金额 —— 由调用方保证。 + */ +@Injectable() +export class TaxService { + private readonly logger = new Logger(TaxService.name); + + constructor(private readonly prisma: PrismaService) {} + + // ─── 数值精度工具 ─────────────────────────────────── + + round2(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100; + } + + // ─── 税码解析 ─────────────────────────────────────── + + /** + * 解析税码。 + * 优先使用显式 taxCodeId → 回退到公司默认税码 → 硬编码 13% 兜底。 + * 兜底时写入 AuditLog(仅当 auditContext 存在时)。 + */ + async resolveTaxCode( + companyId: string, + taxCodeId?: string | null, + auditContext?: { operatorId: string; entity: string; entityId: string }, + ): Promise { + // 1. 显式指定 + if (taxCodeId) { + const taxCode = await this.prisma.taxCode.findFirst({ + where: { id: taxCodeId, companyId, active: true }, + }); + if (!taxCode) { + throw new BadRequestException('税码不存在或已停用,请确认税码选择'); + } + return this.toResolved(taxCode, false); + } + + // 2. 公司默认税码 + const defaultTaxCode = await this.prisma.taxCode.findFirst({ + where: { companyId, isDefault: true, active: true }, + orderBy: { updatedAt: 'desc' }, + }); + if (defaultTaxCode) { + return this.toResolved(defaultTaxCode, false); + } + + // 3. 硬编码兜底 + this.logger.warn( + `未配置默认税码,使用 13% 默认税率兜底: companyId=${companyId}`, + ); + + if (auditContext) { + await this.prisma.auditLog.create({ + data: { + userId: auditContext.operatorId, + action: 'TAX_FALLBACK', + entity: auditContext.entity, + entityId: auditContext.entityId, + details: { + reason: '未配置默认税码', + fallbackRate: 0.13, + companyId, + }, + companyId, + }, + }); + } + + return { + id: null, + code: 'FALLBACK_13', + name: '默认税率 13%', + rate: 0.13, + isTaxInclusive: true, + taxNature: TaxNature.OUTPUT, + outputAccountId: null, + inputAccountId: null, + accountId: null, + isFallback: true, + }; + } + + /** + * 根据 taxNature 获取对应的税务科目 ID: + * OUTPUT → outputAccountId,INPUT → inputAccountId。 + * 如果对应字段为空,回退到 accountId(兼容旧数据)。 + */ + getTaxAccountId( + resolved: ResolvedTaxCode, + taxNature?: TaxNature, + ): string | null { + const nature = taxNature ?? resolved.taxNature; + if (nature === TaxNature.INPUT) { + return resolved.inputAccountId ?? resolved.accountId; + } + return resolved.outputAccountId ?? resolved.accountId; + } + + // ─── 价税计算 ─────────────────────────────────────── + + /** + * 含税/未税统一计算。 + * @param baseAmount 基础金额(含税金额或未税金额,取决于 isTaxInclusive) + * @param taxRate 税率 (0-1) + * @param isTaxInclusive true=含税价,false=未税价 + */ + calcTaxBreakdown( + baseAmount: number, + taxRate: number, + isTaxInclusive: boolean, + taxNature: TaxNature = TaxNature.OUTPUT, + ): TaxBreakdown { + const safeRate = Math.max(0, Math.min(1, Number(taxRate ?? 0))); + + if (isTaxInclusive) { + const subTotal = this.round2(baseAmount / (1 + safeRate)); + const taxAmount = this.round2(baseAmount - subTotal); + return { + subTotal, + taxAmount, + total: this.round2(baseAmount), + taxRate: safeRate, + taxNature, + }; + } + + const subTotal = this.round2(baseAmount); + const taxAmount = this.round2(subTotal * safeRate); + const total = this.round2(subTotal + taxAmount); + return { subTotal, taxAmount, total, taxRate: safeRate, taxNature }; + } + + /** + * 从含税总价反算 —— 兼容 FinanceService 的旧接口。 + */ + calcTaxFromTotal( + total: number, + taxRate: number, + taxNature: TaxNature = TaxNature.OUTPUT, + ): TaxBreakdown { + const safeRate = Math.max(0, Math.min(1, Number(taxRate ?? 0))); + const subTotal = this.round2(total / (1 + safeRate)); + const taxAmount = this.round2(total - subTotal); + return { subTotal, taxAmount, total: this.round2(total), taxRate: safeRate, taxNature }; + } + + // ─── 内部方法 ────────────────────────────────────── + + private toResolved( + tc: { + id: string; + code: string; + name: string; + rate: number; + isTaxInclusive: boolean; + taxNature: TaxNature; + outputAccountId: string | null; + inputAccountId: string | null; + accountId: string | null; + }, + isFallback: boolean, + ): ResolvedTaxCode { + return { + id: tc.id, + code: tc.code, + name: tc.name, + rate: Number(tc.rate), + isTaxInclusive: tc.isTaxInclusive, + taxNature: tc.taxNature, + outputAccountId: tc.outputAccountId, + inputAccountId: tc.inputAccountId, + accountId: tc.accountId, + isFallback, + }; + } +} diff --git a/apps/api/src/finance/accounting.service.ts b/apps/api/src/finance/accounting.service.ts index a34214e..1e8087e 100644 --- a/apps/api/src/finance/accounting.service.ts +++ b/apps/api/src/finance/accounting.service.ts @@ -1,6 +1,7 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; -import { EntryPostingStatus, JournalType, Prisma } from '@prisma/client'; -import { PrismaService } from '../prisma/prisma.service'; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { EntryPostingStatus, JournalType, TaxNature, Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import { TaxService } from "../core/tax/tax.service"; interface JournalLineInput { accountCode: string; @@ -28,7 +29,10 @@ interface CreateBalancedEntryInput { export class AccountingService { private readonly logger = new Logger(AccountingService.name); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly taxService: TaxService, + ) {} async postStockDepletedEntry(payload: { companyId: string; @@ -44,36 +48,36 @@ export class AccountingService { }); const unitCost = Number(payload.unitCost ?? material?.unitPrice ?? 0); - const amount = this.round2(unitCost * Number(payload.quantity ?? 0)); + const amount = this.taxService.round2(unitCost * Number(payload.quantity ?? 0)); if (amount <= 0) { this.logger.warn( - `跳过零成本库存出库凭证: material=${payload.materialId}`, + "跳过零成本库存出库凭证: material=" + payload.materialId, ); return null; } return this.createBalancedEntry({ companyId: payload.companyId, - journalCode: 'INV', - journalName: 'Inventory Journal', + journalCode: "INV", + journalName: "Inventory Journal", journalType: JournalType.INVENTORY, ref: payload.referenceNo, - description: `库存出库自动凭证: ${material?.name ?? payload.materialId}`, + description: "库存出库自动凭证: " + (material?.name ?? payload.materialId), createdBy: payload.operatorId, lines: [ { - accountCode: '6401', - accountName: '主营业务成本', - accountType: 'EXPENSE', + accountCode: "6401", + accountName: "主营业务成本", + accountType: "EXPENSE", debit: amount, - memo: '库存出库结转成本', + memo: "库存出库结转成本", }, { - accountCode: '1405', - accountName: '库存商品', - accountType: 'ASSET', + accountCode: "1405", + accountName: "库存商品", + accountType: "ASSET", credit: amount, - memo: '库存出库结转成本', + memo: "库存出库结转成本", }, ], }); @@ -83,6 +87,7 @@ export class AccountingService { companyId: string; invoiceId: string; taxCodeId?: string | null; + taxAccountId?: string | null; taxRate?: number; operatorId?: string; }) { @@ -90,88 +95,109 @@ export class AccountingService { where: { id: payload.invoiceId, companyId: payload.companyId }, include: { order: { select: { orderNo: true, partnerId: true } }, - taxCode: { include: { account: true } }, + taxCode: { + include: { + account: true, + outputAccount: true, + inputAccount: true, + }, + }, }, }); if (!invoice) { - throw new BadRequestException('发票不存在,无法生成凭证'); + throw new BadRequestException("发票不存在,无法生成凭证"); } - const amount = this.round2(Number(invoice.amount)); + const amount = this.taxService.round2(Number(invoice.amount)); if (amount <= 0) { - throw new BadRequestException('发票金额必须大于0'); + throw new BadRequestException("发票金额必须大于0"); } - let revenue = this.round2(Number(invoice.subTotal ?? 0)); - let tax = this.round2(Number(invoice.taxAmount ?? 0)); + // 使用发票快照中的价税数据,不重新计算 + let revenue = this.taxService.round2(Number(invoice.subTotal ?? 0)); + let tax = this.taxService.round2(Number(invoice.taxAmount ?? 0)); if (revenue <= 0 && tax <= 0) { + // 仅对历史遗留数据做兜底 const fallbackRate = Math.max( 0, - Math.min( - 1, - Number(payload.taxRate ?? invoice.taxCode?.rate ?? 0.13), - ), + Math.min(1, Number(invoice.taxRate ?? payload.taxRate ?? invoice.taxCode?.rate ?? 0.13)), ); - revenue = this.round2(amount / (1 + fallbackRate)); - tax = this.round2(amount - revenue); + revenue = this.taxService.round2(amount / (1 + fallbackRate)); + tax = this.taxService.round2(amount - revenue); this.logger.warn( - `发票未包含税额快照,使用兜底税率计算: invoice=${invoice.invoiceNo}`, + "发票未包含税额快照,使用兜底税率计算: invoice=" + invoice.invoiceNo, ); } - const resolvedTaxCode = invoice.taxCode - ? invoice.taxCode - : payload.taxCodeId - ? await this.prisma.taxCode.findFirst({ - where: { - id: payload.taxCodeId, - companyId: payload.companyId, - active: true, - }, - include: { account: true }, - }) - : null; + // 确定税务科目 + let taxAccountCode = "222101"; + let taxAccountName = "应交税费-销项税"; + let taxAccountType = "LIABILITY"; - const taxAccount = resolvedTaxCode?.account; - if (!taxAccount) { - this.logger.warn( - `未配置税码会计科目,使用默认销项税科目: invoice=${invoice.invoiceNo}`, - ); + if (payload.taxAccountId) { + const account = await this.prisma.account.findFirst({ + where: { id: payload.taxAccountId, companyId: payload.companyId }, + }); + if (account) { + taxAccountCode = account.code; + taxAccountName = account.name; + taxAccountType = account.type; + } + } else if (invoice.taxCode) { + const tc = invoice.taxCode; + const nature = (tc.taxNature as TaxNature) ?? TaxNature.OUTPUT; + const accountId = nature === TaxNature.INPUT + ? (tc.inputAccountId ?? tc.accountId) + : (tc.outputAccountId ?? tc.accountId); + if (accountId) { + const account = await this.prisma.account.findFirst({ + where: { id: accountId, companyId: payload.companyId }, + }); + if (account) { + taxAccountCode = account.code; + taxAccountName = account.name; + taxAccountType = account.type; + } + } else { + this.logger.warn( + "未配置税码会计科目,使用默认销项税科目: invoice=" + invoice.invoiceNo, + ); + } } return this.createBalancedEntry({ companyId: payload.companyId, - journalCode: 'SAL', - journalName: 'Sales Journal', + journalCode: "SAL", + journalName: "Sales Journal", journalType: JournalType.SALES, ref: invoice.invoiceNo, - description: `销售开票自动凭证: ${invoice.invoiceNo}`, + description: "销售开票自动凭证: " + invoice.invoiceNo, createdBy: payload.operatorId, lines: [ { - accountCode: '1122', - accountName: '应收账款', - accountType: 'ASSET', + accountCode: "1122", + accountName: "应收账款", + accountType: "ASSET", debit: amount, partnerId: invoice.order.partnerId, - memo: `应收 ${invoice.invoiceNo}`, + memo: "应收 " + invoice.invoiceNo, }, { - accountCode: '6001', - accountName: '主营业务收入', - accountType: 'REVENUE', + accountCode: "6001", + accountName: "主营业务收入", + accountType: "REVENUE", credit: revenue, partnerId: invoice.order.partnerId, - memo: `收入 ${invoice.invoiceNo}`, + memo: "收入 " + invoice.invoiceNo, }, { - accountCode: taxAccount?.code ?? '222101', - accountName: taxAccount?.name ?? '应交税费-销项税', - accountType: taxAccount?.type ?? 'LIABILITY', + accountCode: taxAccountCode, + accountName: taxAccountName, + accountType: taxAccountType, credit: tax, - memo: `销项税 ${invoice.invoiceNo}`, + memo: "销项税 " + invoice.invoiceNo, }, ], }); @@ -182,27 +208,27 @@ export class AccountingService { return this.prisma.$transaction(async (tx) => { await tx.$executeRawUnsafe( - 'SELECT pg_advisory_xact_lock(hashtext($1))', - `journal-entry-${input.companyId}`, + "SELECT pg_advisory_xact_lock(hashtext($1))", + "journal-entry-" + input.companyId, ); const lines = input.lines.map((line) => ({ ...line, - debit: this.round2(Number(line.debit ?? 0)), - credit: this.round2(Number(line.credit ?? 0)), + debit: this.taxService.round2(Number(line.debit ?? 0)), + credit: this.taxService.round2(Number(line.credit ?? 0)), })); this.validateLines(lines); - const totalDebit = this.round2( + const totalDebit = this.taxService.round2( lines.reduce((sum, line) => sum + line.debit, 0), ); - const totalCredit = this.round2( + const totalCredit = this.taxService.round2( lines.reduce((sum, line) => sum + line.credit, 0), ); if (totalDebit !== totalCredit) { throw new BadRequestException( - `借贷不平衡: debit=${totalDebit}, credit=${totalCredit}`, + "借贷不平衡: debit=" + totalDebit + ", credit=" + totalCredit, ); } @@ -263,7 +289,7 @@ export class AccountingService { include: { lines: { include: { account: true }, - orderBy: { lineNo: 'asc' }, + orderBy: { lineNo: "asc" }, }, journal: true, }, @@ -281,16 +307,16 @@ export class AccountingService { private async ensureDefaultMasterData(companyId: string) { await this.prisma.journal.upsert({ - where: { companyId_code: { companyId, code: 'GEN' } }, + where: { companyId_code: { companyId, code: "GEN" } }, update: { - name: 'General Journal', + name: "General Journal", type: JournalType.GENERAL, isActive: true, }, create: { companyId, - code: 'GEN', - name: 'General Journal', + code: "GEN", + name: "General Journal", type: JournalType.GENERAL, }, }); @@ -326,32 +352,26 @@ export class AccountingService { private validateLines(lines: Array<{ debit: number; credit: number }>) { if (!lines.length) { - throw new BadRequestException('凭证分录不能为空'); + throw new BadRequestException("凭证分录不能为空"); } for (const line of lines) { if (line.debit < 0 || line.credit < 0) { - throw new BadRequestException('分录金额不能为负数'); + throw new BadRequestException("分录金额不能为负数"); } if ( (line.debit === 0 && line.credit === 0) || (line.debit > 0 && line.credit > 0) ) { - throw new BadRequestException('每行分录必须仅填写借方或贷方'); + throw new BadRequestException("每行分录必须仅填写借方或贷方"); } } } private generateEntryNo() { const now = new Date(); - const datePart = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String( - now.getDate(), - ).padStart(2, '0')}`; + const datePart = now.getFullYear() + String(now.getMonth() + 1).padStart(2, "0") + String(now.getDate()).padStart(2, "0"); const suffix = String(now.getTime()).slice(-6); - return `JE-${datePart}-${suffix}`; + return "JE-" + datePart + "-" + suffix; } - - private round2(value: number) { - return Number((value + Number.EPSILON).toFixed(2)); - } -} +} \ No newline at end of file diff --git a/apps/api/src/finance/dto/tax-code.dto.ts b/apps/api/src/finance/dto/tax-code.dto.ts new file mode 100644 index 0000000..4e1d784 --- /dev/null +++ b/apps/api/src/finance/dto/tax-code.dto.ts @@ -0,0 +1,109 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsNumber, + IsOptional, + IsBoolean, + IsEnum, + Min, + Max, +} from 'class-validator'; +import { TaxNature } from '@prisma/client'; + +export class CreateTaxCodeDto { + @ApiProperty({ description: '税码编码,如 VAT_13、GST_5' }) + @IsString() + @IsNotEmpty() + code!: string; + + @ApiProperty({ description: '税码名称' }) + @IsString() + @IsNotEmpty() + name!: string; + + @ApiProperty({ description: '税率 (0-1)', example: 0.13 }) + @IsNumber() + @Min(0) + @Max(1) + rate!: number; + + @ApiProperty({ description: '是否含税计价', default: true }) + @IsOptional() + @IsBoolean() + isTaxInclusive?: boolean; + + @ApiProperty({ description: '设置为公司默认税码', default: false }) + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @ApiPropertyOptional({ description: '税务属性: OUTPUT=销项税, INPUT=进项税', enum: TaxNature, default: TaxNature.OUTPUT }) + @IsOptional() + @IsEnum(TaxNature) + taxNature?: TaxNature; + + @ApiPropertyOptional({ description: '(旧)税务科目 ID' }) + @IsOptional() + @IsString() + accountId?: string; + + @ApiPropertyOptional({ description: '销项税科目 ID' }) + @IsOptional() + @IsString() + outputAccountId?: string; + + @ApiPropertyOptional({ description: '进项税科目 ID' }) + @IsOptional() + @IsString() + inputAccountId?: string; +} + +export class UpdateTaxCodeDto { + @ApiPropertyOptional({ description: '税码名称' }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ description: '税率 (0-1)', example: 0.13 }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(1) + rate?: number; + + @ApiPropertyOptional({ description: '是否含税计价' }) + @IsOptional() + @IsBoolean() + isTaxInclusive?: boolean; + + @ApiPropertyOptional({ description: '是否默认税码' }) + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @ApiPropertyOptional({ description: '是否启用' }) + @IsOptional() + @IsBoolean() + active?: boolean; + + @ApiPropertyOptional({ description: '税务属性', enum: TaxNature }) + @IsOptional() + @IsEnum(TaxNature) + taxNature?: TaxNature; + + @ApiPropertyOptional({ description: '销项税科目 ID' }) + @IsOptional() + @IsString() + outputAccountId?: string; + + @ApiPropertyOptional({ description: '进项税科目 ID' }) + @IsOptional() + @IsString() + inputAccountId?: string; + + @ApiPropertyOptional({ description: '(旧)税务科目 ID' }) + @IsOptional() + @IsString() + accountId?: string; +} diff --git a/apps/api/src/finance/finance.controller.spec.ts b/apps/api/src/finance/finance.controller.spec.ts index a41ee81..ba03694 100644 --- a/apps/api/src/finance/finance.controller.spec.ts +++ b/apps/api/src/finance/finance.controller.spec.ts @@ -1,11 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { FinanceController } from './finance.controller'; -import { FinanceService } from './finance.service'; -import { FinanceDlqService } from './finance-dlq.service'; -import { JwtAuthGuard } from '../core/guards/jwt-auth.guard'; -import { TenantGuard } from '../core/guards/tenant.guard'; - -describe('FinanceController', () => { +import { Test, TestingModule } from "@nestjs/testing"; +import { FinanceController } from "./finance.controller"; +import { FinanceService } from "./finance.service"; +import { FinanceDlqService } from "./finance-dlq.service"; +import { JwtAuthGuard } from "../core/guards/jwt-auth.guard"; +import { TenantGuard } from "../core/guards/tenant.guard"; + +describe("FinanceController", () => { let controller: FinanceController; const mockFinanceService = { @@ -37,93 +37,85 @@ describe('FinanceController', () => { controller = module.get(FinanceController); }); - it('should be defined', () => { + it("should be defined", () => { expect(controller).toBeDefined(); }); - describe('createInvoice', () => { - it('should create an invoice', async () => { - const expected = { id: 'inv1', amount: 1000 }; + describe("createInvoice", () => { + it("should create an invoice", async () => { + const expected = { id: "inv1", amount: 1000 }; mockFinanceService.createInvoice.mockResolvedValue(expected); const result = await controller.createInvoice( - 'c1', - { id: 'u1', email: 'test@example.com' }, - { - orderId: 'o1', - amount: 1000, - dueDate: '2025-12-31', - }, + "c1", + { id: "u1", email: "test@example.com" }, + { orderId: "o1", amount: 1000, dueDate: "2025-12-31" }, ); expect(result).toEqual(expected); expect(mockFinanceService.createInvoice).toHaveBeenCalledWith( - 'c1', - { orderId: 'o1', amount: 1000, dueDate: '2025-12-31' }, - 'u1', + "c1", + { orderId: "o1", amount: 1000, dueDate: "2025-12-31" }, + "u1", ); }); }); - describe('getInvoices', () => { - it('should return invoices', async () => { + describe("getInvoices", () => { + it("should return invoices", async () => { const expected = { data: [], total: 0 }; mockFinanceService.getInvoices.mockResolvedValue(expected); - const result = await controller.getInvoices('c1', { page: 1, limit: 20 }); + const result = await controller.getInvoices("c1", { page: 1, limit: 20 }); expect(result).toEqual(expected); }); }); - describe('recordPayment', () => { - it('should record a payment', async () => { - const expected = { id: 'pay1', amount: 500 }; + describe("recordPayment", () => { + it("should record a payment", async () => { + const expected = { id: "pay1", amount: 500 }; mockFinanceService.recordPayment.mockResolvedValue(expected); - const result = await controller.recordPayment('c1', 'inv1', { + const result = await controller.recordPayment("c1", "inv1", { amount: 500, - method: 'BANK_TRANSFER', + method: "BANK_TRANSFER", }); expect(result).toEqual(expected); }); }); - describe('postInvoice', () => { - it('should post an invoice', async () => { - const expected = { id: 'inv1', postingStatus: 'POSTED' }; + describe("postInvoice", () => { + it("should post an invoice (no taxCodeId/taxRate param)", async () => { + const expected = { id: "inv1", postingStatus: "POSTED" }; mockFinanceService.postInvoice.mockResolvedValue(expected); - const result = await controller.postInvoice('c1', { id: 'u1', email: 'test@example.com' }, 'inv1', { - taxRate: 0.13, - }); + const result = await controller.postInvoice("c1", { id: "u1", email: "test@example.com" }, "inv1"); expect(result).toEqual(expected); expect(mockFinanceService.postInvoice).toHaveBeenCalledWith( - 'c1', - 'inv1', - 'u1', - undefined, - 0.13, + "c1", + "inv1", + "u1", ); }); }); - describe('getDlq', () => { - it('should list DLQ items', async () => { - const expected = [{ id: 'dlq1' }]; + describe("getDlq", () => { + it("should list DLQ items", async () => { + const expected = [{ id: "dlq1" }]; mockFinanceDlqService.list.mockResolvedValue(expected); - const result = await controller.getDlq('10'); + const result = await controller.getDlq("10"); expect(result).toEqual(expected); expect(mockFinanceDlqService.list).toHaveBeenCalledWith(10); }); }); - describe('retryDlq', () => { - it('should retry pending DLQ items', async () => { + describe("retryDlq", () => { + it("should retry pending DLQ items", async () => { const expected = { retried: 5 }; mockFinanceDlqService.retryPending.mockResolvedValue(expected); @@ -133,4 +125,4 @@ describe('FinanceController', () => { expect(mockFinanceDlqService.retryPending).toHaveBeenCalledWith(10); }); }); -}); +}); \ No newline at end of file diff --git a/apps/api/src/finance/finance.controller.ts b/apps/api/src/finance/finance.controller.ts index 4b942cd..0f74b01 100644 --- a/apps/api/src/finance/finance.controller.ts +++ b/apps/api/src/finance/finance.controller.ts @@ -6,34 +6,30 @@ import { Param, UseGuards, Query, -} from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; -import { FinanceService } from './finance.service'; -import { FinanceDlqService } from './finance-dlq.service'; -import { JwtAuthGuard } from '../core/guards/jwt-auth.guard'; -import { TenantGuard } from '../core/guards/tenant.guard'; -import { CurrentCompany } from '../core/decorators/current-company.decorator'; -import { CurrentUser } from '../core/decorators/current-user.decorator'; -import { - CreateInvoiceDto, - CreatePaymentDto, - PostInvoiceDto, -} from './dto/finance.dto'; -import { PaginationDto } from '../core/dto/pagination.dto'; -import type { JwtUserPayload } from '../core/http/request.types'; +} from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger"; +import { FinanceService } from "./finance.service"; +import { FinanceDlqService } from "./finance-dlq.service"; +import { JwtAuthGuard } from "../core/guards/jwt-auth.guard"; +import { TenantGuard } from "../core/guards/tenant.guard"; +import { CurrentCompany } from "../core/decorators/current-company.decorator"; +import { CurrentUser } from "../core/decorators/current-user.decorator"; +import { CreateInvoiceDto, CreatePaymentDto } from "./dto/finance.dto"; +import { PaginationDto } from "../core/dto/pagination.dto"; +import type { JwtUserPayload } from "../core/http/request.types"; -@ApiTags('财务管理 (Finance)') +@ApiTags("财务管理 (Finance)") @ApiBearerAuth() @UseGuards(JwtAuthGuard, TenantGuard) -@Controller('finance') +@Controller("finance") export class FinanceController { constructor( private readonly financeService: FinanceService, private readonly financeDlqService: FinanceDlqService, ) {} - @Post('invoices') - @ApiOperation({ summary: '创建应收发票' }) + @Post("invoices") + @ApiOperation({ summary: "创建应收发票" }) async createInvoice( @CurrentCompany() companyId: string, @CurrentUser() user: JwtUserPayload, @@ -42,8 +38,8 @@ export class FinanceController { return this.financeService.createInvoice(companyId, dto, user.id); } - @Get('invoices') - @ApiOperation({ summary: '获取发票列表' }) + @Get("invoices") + @ApiOperation({ summary: "获取发票列表" }) async getInvoices( @CurrentCompany() companyId: string, @Query() pagination: PaginationDto, @@ -51,42 +47,35 @@ export class FinanceController { return this.financeService.getInvoices(companyId, pagination); } - @Post('invoices/:id/payments') - @ApiOperation({ summary: '登记发票收款' }) + @Post("invoices/:id/payments") + @ApiOperation({ summary: "登记发票收款" }) async recordPayment( @CurrentCompany() companyId: string, - @Param('id') invoiceId: string, + @Param("id") invoiceId: string, @Body() dto: CreatePaymentDto, ) { return this.financeService.recordPayment(companyId, invoiceId, dto); } - @Post('invoices/:id/post') - @ApiOperation({ summary: '发票过账(触发收入凭证生成)' }) + @Post("invoices/:id/post") + @ApiOperation({ summary: "发票过账(触发收入凭证生成)" }) async postInvoice( @CurrentCompany() companyId: string, @CurrentUser() user: JwtUserPayload, - @Param('id') invoiceId: string, - @Body() dto: PostInvoiceDto, + @Param("id") invoiceId: string, ) { - return this.financeService.postInvoice( - companyId, - invoiceId, - user.id, - dto.taxCodeId, - dto.taxRate, - ); + return this.financeService.postInvoice(companyId, invoiceId, user.id); } - @Get('dlq') - @ApiOperation({ summary: '查看财务事件补偿队列' }) - async getDlq(@Query('limit') limit?: string) { + @Get("dlq") + @ApiOperation({ summary: "查看财务事件补偿队列" }) + async getDlq(@Query("limit") limit?: string) { return this.financeDlqService.list(Number(limit ?? 50)); } - @Post('dlq/retry') - @ApiOperation({ summary: '重试财务事件补偿队列' }) + @Post("dlq/retry") + @ApiOperation({ summary: "重试财务事件补偿队列" }) async retryDlq(@Body() body?: { limit?: number }) { return this.financeDlqService.retryPending(body?.limit ?? 20); } -} +} \ No newline at end of file diff --git a/apps/api/src/finance/finance.module.ts b/apps/api/src/finance/finance.module.ts index 232e0cf..d507c28 100644 --- a/apps/api/src/finance/finance.module.ts +++ b/apps/api/src/finance/finance.module.ts @@ -1,15 +1,16 @@ -import { Module } from '@nestjs/common'; -import { FinanceController } from './finance.controller'; -import { FinanceService } from './finance.service'; -import { PrismaModule } from '../prisma/prisma.module'; -import { AccountingService } from './accounting.service'; -import { FinanceDlqService } from './finance-dlq.service'; -import { FinanceBridgeListener } from './finance-bridge.listener'; -import { EventQueueModule } from '../core/events/event-queue.module'; +import { Module } from "@nestjs/common"; +import { FinanceController } from "./finance.controller"; +import { FinanceService } from "./finance.service"; +import { TaxCodeController } from "./tax-code.controller"; +import { PrismaModule } from "../prisma/prisma.module"; +import { AccountingService } from "./accounting.service"; +import { FinanceDlqService } from "./finance-dlq.service"; +import { FinanceBridgeListener } from "./finance-bridge.listener"; +import { EventQueueModule } from "../core/events/event-queue.module"; @Module({ imports: [PrismaModule, EventQueueModule], - controllers: [FinanceController], + controllers: [FinanceController, TaxCodeController], providers: [ FinanceService, AccountingService, @@ -18,4 +19,4 @@ import { EventQueueModule } from '../core/events/event-queue.module'; ], exports: [AccountingService, FinanceDlqService], }) -export class FinanceModule {} +export class FinanceModule {} \ No newline at end of file diff --git a/apps/api/src/finance/finance.service.spec.ts b/apps/api/src/finance/finance.service.spec.ts index 1e012ff..c694d9f 100644 --- a/apps/api/src/finance/finance.service.spec.ts +++ b/apps/api/src/finance/finance.service.spec.ts @@ -1,5 +1,6 @@ -import { NotFoundException } from '@nestjs/common'; -import { FinanceService } from './finance.service'; +import { NotFoundException } from "@nestjs/common"; +import { TaxNature } from "@prisma/client"; +import { FinanceService } from "./finance.service"; type MockPrisma = { order: { findFirst: jest.Mock }; @@ -13,6 +14,7 @@ type MockPrisma = { }; payment: { create: jest.Mock }; auditLog: { create: jest.Mock }; + account: { findFirst: jest.Mock }; $transaction: jest.Mock; }; @@ -21,7 +23,14 @@ type MockTx = { invoice: { update: jest.Mock }; }; -describe('FinanceService', () => { +const mockTaxService = { + round2: jest.fn((v: number) => Math.round((v + Number.EPSILON) * 100) / 100), + resolveTaxCode: jest.fn(), + calcTaxFromTotal: jest.fn(), + getTaxAccountId: jest.fn(), +}; + +describe("FinanceService", () => { const prisma: MockPrisma = { order: { findFirst: jest.fn() }, taxCode: { findFirst: jest.fn() }, @@ -34,6 +43,7 @@ describe('FinanceService', () => { }, payment: { create: jest.fn() }, auditLog: { create: jest.fn() }, + account: { findFirst: jest.fn() }, $transaction: jest.fn(), }; @@ -53,63 +63,77 @@ describe('FinanceService', () => { ); service = new FinanceService( prisma as unknown as ConstructorParameters[0], - eventEmitter as unknown as ConstructorParameters< - typeof FinanceService - >[1], + eventEmitter as unknown as ConstructorParameters[1], + mockTaxService as unknown as ConstructorParameters[2], ); }); - it('should be defined', () => { + it("should be defined", () => { expect(service).toBeDefined(); }); - describe('createInvoice', () => { - it('should create an invoice when order exists', async () => { - prisma.order.findFirst.mockResolvedValue({ id: 'o1', companyId: 'c1' }); + describe("createInvoice", () => { + it("should create an invoice when order exists", async () => { + prisma.order.findFirst.mockResolvedValue({ id: "o1", companyId: "c1", taxCodeId: null }); + mockTaxService.resolveTaxCode.mockResolvedValue({ + id: "tc1", + code: "VAT_13", + name: "增值税13%", + rate: 0.13, + isTaxInclusive: true, + taxNature: TaxNature.OUTPUT, + outputAccountId: null, + inputAccountId: null, + accountId: null, + isFallback: false, + }); + mockTaxService.calcTaxFromTotal.mockReturnValue({ + subTotal: 884.96, + taxAmount: 115.04, + total: 1000, + taxRate: 0.13, + taxNature: TaxNature.OUTPUT, + }); prisma.invoice.create.mockResolvedValue({ - id: 'inv1', - invoiceNo: 'INV-123', - orderId: 'o1', + id: "inv1", + invoiceNo: "INV-123", + orderId: "o1", amount: 1000, - status: 'UNPAID', - companyId: 'c1', + status: "UNPAID", + companyId: "c1", }); prisma.auditLog.create.mockResolvedValue({}); const result = await service.createInvoice( - 'c1', - { - orderId: 'o1', - amount: 1000, - dueDate: '2025-12-31', - }, - 'u1', + "c1", + { orderId: "o1", amount: 1000, dueDate: "2025-12-31" }, + "u1", ); - expect(result.id).toBe('inv1'); + expect(result.id).toBe("inv1"); expect(prisma.invoice.create).toHaveBeenCalled(); expect(prisma.auditLog.create).toHaveBeenCalled(); }); - it('should throw NotFoundException when order not found', async () => { + it("should throw NotFoundException when order not found", async () => { prisma.order.findFirst.mockResolvedValue(null); await expect( service.createInvoice( - 'c1', - { orderId: 'x', amount: 100, dueDate: '2025-01-01' }, - 'u1', + "c1", + { orderId: "x", amount: 100, dueDate: "2025-01-01" }, + "u1", ), ).rejects.toBeInstanceOf(NotFoundException); }); }); - describe('getInvoices', () => { - it('should return paginated invoices', async () => { - prisma.invoice.findMany.mockResolvedValue([{ id: 'inv1', amount: 1000 }]); + describe("getInvoices", () => { + it("should return paginated invoices", async () => { + prisma.invoice.findMany.mockResolvedValue([{ id: "inv1", amount: 1000 }]); prisma.invoice.count.mockResolvedValue(1); - const result = await service.getInvoices('c1', { page: 1, limit: 20 }); + const result = await service.getInvoices("c1", { page: 1, limit: 20 }); expect(result.data).toHaveLength(1); expect(result.total).toBe(1); @@ -117,120 +141,133 @@ describe('FinanceService', () => { }); }); - describe('recordPayment', () => { - it('should record payment and update invoice to PAID', async () => { + describe("recordPayment", () => { + it("should record payment and update invoice to PAID", async () => { prisma.invoice.findFirst.mockResolvedValue({ - id: 'inv1', + id: "inv1", amount: 1000, - status: 'UNPAID', + status: "UNPAID", payments: [], }); tx.payment.create.mockResolvedValue({ - id: 'pay1', - invoiceId: 'inv1', + id: "pay1", + invoiceId: "inv1", amount: 1000, }); tx.invoice.update.mockResolvedValue({}); - const result = await service.recordPayment('c1', 'inv1', { + const result = await service.recordPayment("c1", "inv1", { amount: 1000, - method: 'BANK_TRANSFER', + method: "BANK_TRANSFER", }); - expect(result.id).toBe('pay1'); + expect(result.id).toBe("pay1"); expect(tx.invoice.update).toHaveBeenCalledWith({ - where: { id: 'inv1' }, - data: { status: 'PAID' }, + where: { id: "inv1" }, + data: { status: "PAID" }, }); }); - it('should set PARTIAL status for partial payment', async () => { + it("should set PARTIAL status for partial payment", async () => { prisma.invoice.findFirst.mockResolvedValue({ - id: 'inv1', + id: "inv1", amount: 1000, - status: 'UNPAID', + status: "UNPAID", payments: [], }); - tx.payment.create.mockResolvedValue({ id: 'pay2', amount: 500 }); + tx.payment.create.mockResolvedValue({ id: "pay2", amount: 500 }); tx.invoice.update.mockResolvedValue({}); - await service.recordPayment('c1', 'inv1', { + await service.recordPayment("c1", "inv1", { amount: 500, - method: 'ALIPAY', + method: "ALIPAY", }); expect(tx.invoice.update).toHaveBeenCalledWith({ - where: { id: 'inv1' }, - data: { status: 'PARTIAL' }, + where: { id: "inv1" }, + data: { status: "PARTIAL" }, }); }); - it('should throw NotFoundException when invoice missing', async () => { + it("should throw NotFoundException when invoice missing", async () => { prisma.invoice.findFirst.mockResolvedValue(null); await expect( - service.recordPayment('c1', 'x', { amount: 100, method: 'ALIPAY' }), + service.recordPayment("c1", "x", { amount: 100, method: "ALIPAY" }), ).rejects.toBeInstanceOf(NotFoundException); }); }); - describe('postInvoice', () => { - it('should post an invoice and emit event', async () => { + describe("postInvoice", () => { + it("should post an invoice using snapshot and emit event", async () => { prisma.invoice.findFirst.mockResolvedValue({ - id: 'inv1', - invoiceNo: 'INV-123', - postingStatus: 'DRAFT', + id: "inv1", + invoiceNo: "INV-123", + postingStatus: "DRAFT", amount: 1000, - subTotal: 0, - taxAmount: 0, - taxCodeId: null, + subTotal: 884.96, + taxAmount: 115.04, + taxRate: 0.13, + taxNature: TaxNature.OUTPUT, + taxCodeId: "tc1", order: { taxCodeId: null }, + taxCode: null, }); prisma.invoice.update.mockResolvedValue({ - id: 'inv1', - invoiceNo: 'INV-123', - postingStatus: 'POSTED', + id: "inv1", + invoiceNo: "INV-123", + postingStatus: "POSTED", }); prisma.auditLog.create.mockResolvedValue({}); + mockTaxService.resolveTaxCode.mockResolvedValue({ + id: "tc1", + code: "VAT_13", + rate: 0.13, + taxNature: TaxNature.OUTPUT, + outputAccountId: null, + inputAccountId: null, + accountId: null, + isFallback: false, + }); + mockTaxService.getTaxAccountId.mockReturnValue(null); - const result = await service.postInvoice('c1', 'inv1', 'u1', undefined, 0.13); + const result = await service.postInvoice("c1", "inv1", "u1"); - expect(result.postingStatus).toBe('POSTED'); + expect(result.postingStatus).toBe("POSTED"); expect(prisma.invoice.update).toHaveBeenCalledWith({ - where: { id: 'inv1' }, - data: expect.objectContaining({ postingStatus: 'POSTED' }), + where: { id: "inv1" }, + data: expect.objectContaining({ postingStatus: "POSTED" }), }); expect(eventEmitter.emit).toHaveBeenCalledWith( - 'finance.invoice.posted', + "finance.invoice.posted", expect.objectContaining({ - companyId: 'c1', - invoiceId: 'inv1', - taxCodeId: null, - taxRate: 0.13, + companyId: "c1", + invoiceId: "inv1", + taxCodeId: "tc1", }), ); }); - it('should skip posting when already POSTED', async () => { + it("should skip posting when already POSTED", async () => { prisma.invoice.findFirst.mockResolvedValue({ - id: 'inv1', - invoiceNo: 'INV-123', - postingStatus: 'POSTED', + id: "inv1", + invoiceNo: "INV-123", + postingStatus: "POSTED", }); - const result = await service.postInvoice('c1', 'inv1', 'u1'); + const result = await service.postInvoice("c1", "inv1", "u1"); - expect((result as { message?: string }).message).toContain('已过账'); + expect((result as { message?: string }).message).toContain("已过账"); expect(prisma.invoice.update).not.toHaveBeenCalled(); expect(eventEmitter.emit).not.toHaveBeenCalled(); }); - it('should throw NotFoundException when invoice not found', async () => { + it("should throw NotFoundException when invoice not found", async () => { prisma.invoice.findFirst.mockResolvedValue(null); await expect( - service.postInvoice('c1', 'nonexistent', 'u1'), + service.postInvoice("c1", "nonexistent", "u1"), ).rejects.toBeInstanceOf(NotFoundException); }); }); -}); +}); \ No newline at end of file diff --git a/apps/api/src/finance/finance.service.ts b/apps/api/src/finance/finance.service.ts index 576ed8a..f29cd22 100644 --- a/apps/api/src/finance/finance.service.ts +++ b/apps/api/src/finance/finance.service.ts @@ -3,12 +3,13 @@ import { Injectable, Logger, NotFoundException, -} from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { EntryPostingStatus, Prisma } from '@prisma/client'; -import { PrismaService } from '../prisma/prisma.service'; -import { CreateInvoiceDto, CreatePaymentDto } from './dto/finance.dto'; -import { PaginationDto } from '../core/dto/pagination.dto'; +} from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { EntryPostingStatus, TaxNature, Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import { TaxService } from "../core/tax/tax.service"; +import { CreateInvoiceDto, CreatePaymentDto } from "./dto/finance.dto"; +import { PaginationDto } from "../core/dto/pagination.dto"; @Injectable() export class FinanceService { @@ -17,42 +18,9 @@ export class FinanceService { constructor( private readonly prisma: PrismaService, private readonly eventEmitter: EventEmitter2, + private readonly taxService: TaxService, ) {} - private round2(value: number) { - return Math.round((value + Number.EPSILON) * 100) / 100; - } - - private calcTaxFromTotal(total: number, taxRate: number) { - const safeRate = Math.max(0, Math.min(1, Number(taxRate ?? 0))); - const subTotal = this.round2(total / (1 + safeRate)); - const taxAmount = this.round2(total - subTotal); - return { subTotal, taxAmount, taxRate: safeRate }; - } - - private async resolveTaxCode(companyId: string, taxCodeId?: string | null) { - if (taxCodeId) { - const taxCode = await this.prisma.taxCode.findFirst({ - where: { id: taxCodeId, companyId, active: true }, - }); - if (!taxCode) { - throw new BadRequestException('税码不存在或已停用,请确认税码选择'); - } - return { ...taxCode, isFallback: false }; - } - - const defaultTaxCode = await this.prisma.taxCode.findFirst({ - where: { companyId, isDefault: true, active: true }, - orderBy: { updatedAt: 'desc' }, - }); - if (defaultTaxCode) { - return { ...defaultTaxCode, isFallback: false }; - } - - this.logger.warn(`未配置默认税码,发票将使用 13% 默认税率: ${companyId}`); - return { id: null, rate: 0.13, isFallback: true }; - } - async createInvoice( companyId: string, dto: CreateInvoiceDto, @@ -62,44 +30,53 @@ export class FinanceService { where: { id: dto.orderId, companyId }, select: { id: true, taxCodeId: true }, }); - if (!order) throw new NotFoundException('找不到销售订单'); + if (!order) throw new NotFoundException("找不到销售订单"); - const resolvedTaxCode = await this.resolveTaxCode( + const resolvedTaxCode = await this.taxService.resolveTaxCode( companyId, dto.taxCodeId ?? order.taxCodeId, + { operatorId, entity: "Invoice", entityId: "new" }, ); - const amount = this.round2(Number(dto.amount)); - const breakdown = this.calcTaxFromTotal(amount, resolvedTaxCode.rate); + const amount = this.taxService.round2(Number(dto.amount)); + const breakdown = this.taxService.calcTaxFromTotal( + amount, + resolvedTaxCode.rate, + resolvedTaxCode.taxNature, + ); const invoice = await this.prisma.invoice.create({ data: { - invoiceNo: `INV-${Date.now()}`, + invoiceNo: "INV-" + Date.now(), orderId: dto.orderId, amount, subTotal: breakdown.subTotal, taxAmount: breakdown.taxAmount, + taxRate: breakdown.taxRate, + taxNature: breakdown.taxNature, taxCodeId: resolvedTaxCode.id ?? null, dueDate: new Date(dto.dueDate), - status: 'UNPAID', + status: "UNPAID", postingStatus: EntryPostingStatus.DRAFT, companyId, }, }); - // 记录审计日志 await this.prisma.auditLog.create({ data: { userId: operatorId, - action: 'CREATE_INVOICE', - entity: 'Invoice', + action: "CREATE_INVOICE", + entity: "Invoice", entityId: invoice.id, details: { amount, orderId: dto.orderId, taxCodeId: resolvedTaxCode.id ?? null, - taxRate: resolvedTaxCode.rate ?? 0, - taxFallback: resolvedTaxCode.isFallback ?? false, + taxRate: breakdown.taxRate, + taxNature: breakdown.taxNature, + subTotal: breakdown.subTotal, + taxAmount: breakdown.taxAmount, + taxFallback: resolvedTaxCode.isFallback, }, companyId, }, @@ -115,8 +92,8 @@ export class FinanceService { const [data, total] = await Promise.all([ this.prisma.invoice.findMany({ where, - include: { order: true, payments: true }, - orderBy: { issuedDate: 'desc' }, + include: { order: true, payments: true, taxCode: true }, + orderBy: { issuedDate: "desc" }, skip: (page - 1) * limit, take: limit, }), @@ -135,7 +112,7 @@ export class FinanceService { where: { id: invoiceId, companyId }, include: { payments: true }, }); - if (!inv) throw new NotFoundException('发票不存在'); + if (!inv) throw new NotFoundException("发票不存在"); return this.prisma.$transaction(async (tx) => { const payment = await tx.payment.create({ @@ -150,8 +127,8 @@ export class FinanceService { inv.payments.reduce((sum, p) => sum + p.amount, 0) + dto.amount; let newStatus = inv.status; - if (totalPaid >= inv.amount) newStatus = 'PAID'; - else if (totalPaid > 0) newStatus = 'PARTIAL'; + if (totalPaid >= inv.amount) newStatus = "PAID"; + else if (totalPaid > 0) newStatus = "PARTIAL"; await tx.invoice.update({ where: { id: invoiceId }, @@ -162,19 +139,24 @@ export class FinanceService { }); } + /** + * 过账发票 — 使用保存在发票上的价税快照,禁止重新按当前税率覆盖历史金额。 + * 只有在 subTotal/taxAmount 均为 0 的历史遗留数据上才做一次性的兜底计算。 + */ async postInvoice( companyId: string, invoiceId: string, operatorId: string, - taxCodeId?: string, - taxRate?: number, ) { const invoice = await this.prisma.invoice.findFirst({ where: { id: invoiceId, companyId }, - include: { order: { select: { taxCodeId: true } } }, + include: { + order: { select: { taxCodeId: true } }, + taxCode: true, + }, }); if (!invoice) { - throw new NotFoundException('发票不存在'); + throw new NotFoundException("发票不存在"); } if (invoice.postingStatus === EntryPostingStatus.POSTED) { @@ -182,24 +164,30 @@ export class FinanceService { invoiceId: invoice.id, invoiceNo: invoice.invoiceNo, postingStatus: invoice.postingStatus, - message: '发票已过账,无需重复处理', + message: "发票已过账,无需重复处理", }; } - const resolvedTaxCode = await this.resolveTaxCode( - companyId, - taxCodeId ?? invoice.taxCodeId ?? invoice.order?.taxCodeId, - ); - const amount = this.round2(Number(invoice.amount)); - let subTotal = this.round2(Number(invoice.subTotal ?? 0)); - let taxAmount = this.round2(Number(invoice.taxAmount ?? 0)); + const amount = this.taxService.round2(Number(invoice.amount)); + let subTotal = this.taxService.round2(Number(invoice.subTotal ?? 0)); + let taxAmount = this.taxService.round2(Number(invoice.taxAmount ?? 0)); const shouldRecalc = subTotal <= 0 && taxAmount <= 0 && amount > 0; if (shouldRecalc) { - const fallbackRate = taxRate ?? resolvedTaxCode.rate ?? 0.13; - const breakdown = this.calcTaxFromTotal(amount, fallbackRate); + const fallbackRate = + Number(invoice.taxRate) > 0 + ? Number(invoice.taxRate) + : Number(invoice.taxCode?.rate ?? 0.13); + const breakdown = this.taxService.calcTaxFromTotal( + amount, + fallbackRate, + invoice.taxNature as TaxNature, + ); subTotal = breakdown.subTotal; taxAmount = breakdown.taxAmount; + this.logger.warn( + "发票缺少价税快照,过账时兜底计算: invoiceNo=" + invoice.invoiceNo + " rate=" + fallbackRate, + ); } const updateData: Prisma.InvoiceUpdateInput = { @@ -209,8 +197,8 @@ export class FinanceService { updateData.subTotal = subTotal; updateData.taxAmount = taxAmount; } - if (!invoice.taxCodeId && resolvedTaxCode.id) { - updateData.taxCode = { connect: { id: resolvedTaxCode.id } }; + if (!invoice.taxCodeId && invoice.order?.taxCodeId) { + updateData.taxCode = { connect: { id: invoice.order.taxCodeId } }; } const updated = await this.prisma.invoice.update({ @@ -218,31 +206,41 @@ export class FinanceService { data: updateData, }); + const taxCodeId = invoice.taxCodeId ?? invoice.order?.taxCodeId ?? null; + let taxAccountId: string | null = null; + if (taxCodeId) { + const resolved = await this.taxService.resolveTaxCode(companyId, taxCodeId); + taxAccountId = this.taxService.getTaxAccountId(resolved); + } + await this.prisma.auditLog.create({ data: { userId: operatorId, - action: 'POST_INVOICE', - entity: 'invoice', + action: "POST_INVOICE", + entity: "invoice", entityId: invoice.id, details: { invoiceNo: invoice.invoiceNo, - taxCodeId: invoice.taxCodeId ?? resolvedTaxCode.id ?? null, - taxRate: taxRate ?? resolvedTaxCode.rate ?? 0.13, - taxFallback: resolvedTaxCode.isFallback ?? false, + taxCodeId, + taxRate: invoice.taxRate ?? 0.13, + taxNature: invoice.taxNature, + taxAccountId, + usedSnapshot: !shouldRecalc, }, companyId, }, }); - this.eventEmitter.emit('finance.invoice.posted', { + this.eventEmitter.emit("finance.invoice.posted", { companyId, - idempotencyKey: `invoice_posted:${invoice.id}`, + idempotencyKey: "invoice_posted:" + invoice.id, invoiceId: invoice.id, - taxCodeId: invoice.taxCodeId ?? resolvedTaxCode.id ?? null, - taxRate: taxRate ?? resolvedTaxCode.rate ?? 0.13, + taxCodeId, + taxAccountId, + taxRate: invoice.taxRate ?? 0.13, operatorId, }); return updated; } -} +} \ No newline at end of file diff --git a/apps/api/src/finance/tax-code.controller.spec.ts b/apps/api/src/finance/tax-code.controller.spec.ts new file mode 100644 index 0000000..087a428 --- /dev/null +++ b/apps/api/src/finance/tax-code.controller.spec.ts @@ -0,0 +1,83 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { TaxCodeController } from './tax-code.controller'; +import { PrismaService } from '../prisma/prisma.service'; +import { JwtAuthGuard } from '../core/guards/jwt-auth.guard'; +import { TenantGuard } from '../core/guards/tenant.guard'; +import { TaxNature } from '@prisma/client'; + +describe('TaxCodeController', () => { + let controller: TaxCodeController; + + const mockPrisma = { + taxCode: { + create: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + }, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + controllers: [TaxCodeController], + providers: [{ provide: PrismaService, useValue: mockPrisma }], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(TenantGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(TaxCodeController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('create', () => { + it('should create a tax code', async () => { + mockPrisma.taxCode.findFirst.mockResolvedValue(null); + mockPrisma.taxCode.create.mockResolvedValue({ + id: 'tc1', + code: 'VAT_13', + name: '增值税13%', + }); + + const result = await controller.create('c1', { + code: 'VAT_13', + name: '增值税13%', + rate: 0.13, + }); + + expect(result.id).toBe('tc1'); + expect(mockPrisma.taxCode.create).toHaveBeenCalled(); + }); + + it('should throw when code already exists', async () => { + mockPrisma.taxCode.findFirst.mockResolvedValue({ id: 'existing' }); + + await expect( + controller.create('c1', { + code: 'VAT_13', + name: '增值税13%', + rate: 0.13, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('list', () => { + it('should return tax codes', async () => { + mockPrisma.taxCode.findMany.mockResolvedValue([ + { id: 'tc1', code: 'VAT_13' }, + ]); + + const result = await controller.list('c1'); + expect(result).toHaveLength(1); + }); + }); +}); diff --git a/apps/api/src/finance/tax-code.controller.ts b/apps/api/src/finance/tax-code.controller.ts new file mode 100644 index 0000000..87ac04f --- /dev/null +++ b/apps/api/src/finance/tax-code.controller.ts @@ -0,0 +1,148 @@ +import { + Controller, + Get, + Post, + Put, + Body, + Param, + UseGuards, + Query, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { TaxNature, Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { JwtAuthGuard } from '../core/guards/jwt-auth.guard'; +import { TenantGuard } from '../core/guards/tenant.guard'; +import { CurrentCompany } from '../core/decorators/current-company.decorator'; +import { CreateTaxCodeDto, UpdateTaxCodeDto } from './dto/tax-code.dto'; +import { BadRequestException } from '@nestjs/common'; + +@ApiTags('税码管理 (TaxCode)') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, TenantGuard) +@Controller('finance/tax-codes') +export class TaxCodeController { + constructor(private readonly prisma: PrismaService) {} + + @Post() + @ApiOperation({ summary: '创建税码' }) + async create( + @CurrentCompany() companyId: string, + @Body() dto: CreateTaxCodeDto, + ) { + const existing = await this.prisma.taxCode.findFirst({ + where: { companyId, code: dto.code }, + }); + if (existing) { + throw new BadRequestException(`税码编码 ${dto.code} 已存在`); + } + + if (dto.isDefault) { + await this.prisma.taxCode.updateMany({ + where: { companyId, isDefault: true }, + data: { isDefault: false }, + }); + } + + return this.prisma.taxCode.create({ + data: { + code: dto.code, + name: dto.name, + rate: dto.rate, + isTaxInclusive: dto.isTaxInclusive ?? true, + isDefault: dto.isDefault ?? false, + active: true, + taxNature: dto.taxNature ?? TaxNature.OUTPUT, + accountId: dto.accountId ?? null, + outputAccountId: dto.outputAccountId ?? null, + inputAccountId: dto.inputAccountId ?? null, + companyId, + }, + }); + } + + @Get() + @ApiOperation({ summary: '获取税码列表' }) + async list( + @CurrentCompany() companyId: string, + @Query('active') active?: string, + ) { + const where: Prisma.TaxCodeWhereInput = { companyId }; + if (active !== undefined) { + where.active = active === 'true'; + } + return this.prisma.taxCode.findMany({ + where, + include: { + account: { select: { id: true, code: true, name: true } }, + outputAccount: { select: { id: true, code: true, name: true } }, + inputAccount: { select: { id: true, code: true, name: true } }, + }, + orderBy: [{ isDefault: 'desc' }, { code: 'asc' }], + }); + } + + @Get(':id') + @ApiOperation({ summary: '获取税码详情' }) + async getById( + @CurrentCompany() companyId: string, + @Param('id') id: string, + ) { + const taxCode = await this.prisma.taxCode.findFirst({ + where: { id, companyId }, + include: { + account: true, + outputAccount: true, + inputAccount: true, + }, + }); + if (!taxCode) throw new BadRequestException('税码不存在'); + return taxCode; + } + + @Put(':id') + @ApiOperation({ summary: '更新税码' }) + async update( + @CurrentCompany() companyId: string, + @Param('id') id: string, + @Body() dto: UpdateTaxCodeDto, + ) { + const existing = await this.prisma.taxCode.findFirst({ + where: { id, companyId }, + }); + if (!existing) throw new BadRequestException('税码不存在'); + + if (dto.isDefault && !existing.isDefault) { + await this.prisma.taxCode.updateMany({ + where: { companyId, isDefault: true }, + data: { isDefault: false }, + }); + } + + const updateData: Prisma.TaxCodeUpdateInput = {}; + if (dto.name !== undefined) updateData.name = dto.name; + if (dto.rate !== undefined) updateData.rate = dto.rate; + if (dto.isTaxInclusive !== undefined) + updateData.isTaxInclusive = dto.isTaxInclusive; + if (dto.isDefault !== undefined) updateData.isDefault = dto.isDefault; + if (dto.active !== undefined) updateData.active = dto.active; + if (dto.taxNature !== undefined) updateData.taxNature = dto.taxNature; + if (dto.accountId !== undefined) + updateData.account = dto.accountId + ? { connect: { id: dto.accountId } } + : { disconnect: true }; + if (dto.outputAccountId !== undefined) + updateData.outputAccount = dto.outputAccountId + ? { connect: { id: dto.outputAccountId } } + : { disconnect: true }; + if (dto.inputAccountId !== undefined) + updateData.inputAccount = dto.inputAccountId + ? { connect: { id: dto.inputAccountId } } + : { disconnect: true }; + + return this.prisma.taxCode.update({ + where: { id }, + data: updateData, + }); + } +} diff --git a/apps/api/src/orders/orders.service.ts b/apps/api/src/orders/orders.service.ts index b5fa42d..27b7179 100644 --- a/apps/api/src/orders/orders.service.ts +++ b/apps/api/src/orders/orders.service.ts @@ -3,11 +3,12 @@ import { NotFoundException, BadRequestException, Logger, -} from '@nestjs/common'; -import { Prisma } from '@prisma/client'; -import { PrismaService } from '../prisma/prisma.service'; -import { PaginationDto } from '../core/dto/pagination.dto'; -import { EventQueueService } from '../core/events/event-queue.service'; +} from "@nestjs/common"; +import { TaxNature, Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import { TaxService } from "../core/tax/tax.service"; +import { PaginationDto } from "../core/dto/pagination.dto"; +import { EventQueueService } from "../core/events/event-queue.service"; interface CreateOrderItemInput { productId: string; @@ -32,86 +33,36 @@ export class OrdersService { constructor( private prisma: PrismaService, private readonly eventQueueService: EventQueueService, + private readonly taxService: TaxService, ) {} - private round2(value: number) { - return Math.round((value + Number.EPSILON) * 100) / 100; - } - - private calcTaxBreakdown( - baseAmount: number, - taxRate: number, - isTaxInclusive: boolean, - ) { - const safeRate = Math.max(0, Math.min(1, Number(taxRate ?? 0))); - if (isTaxInclusive) { - const subTotal = this.round2(baseAmount / (1 + safeRate)); - const taxAmount = this.round2(baseAmount - subTotal); - return { - subTotal, - taxAmount, - total: this.round2(baseAmount), - }; - } - - const subTotal = this.round2(baseAmount); - const taxAmount = this.round2(subTotal * safeRate); - const total = this.round2(subTotal + taxAmount); - return { subTotal, taxAmount, total }; - } - - private async resolveTaxCode(companyId: string, taxCodeId?: string | null) { - if (taxCodeId) { - const taxCode = await this.prisma.taxCode.findFirst({ - where: { id: taxCodeId, companyId, active: true }, - }); - if (!taxCode) { - throw new BadRequestException('税码不存在或已停用,请确认税码选择'); - } - return { ...taxCode, isFallback: false }; - } - - const defaultTaxCode = await this.prisma.taxCode.findFirst({ - where: { companyId, isDefault: true, active: true }, - orderBy: { updatedAt: 'desc' }, - }); - if (defaultTaxCode) { - return { ...defaultTaxCode, isFallback: false }; - } - - this.logger.warn( - `未配置默认税码,订单将使用 13% 默认税率: companyId=${companyId}`, - ); - return { - id: null, - rate: 0.13, - isTaxInclusive: true, - isFallback: true, - }; - } - async createOrder(companyId: string, userId: string, data: CreateOrderInput) { const { partnerId, items, aiSummary, expectedDate, notes, taxCodeId } = data; - // 自动生成订单号 - const orderNo = `ORD-${new Date().getFullYear()}${String(new Date().getMonth() + 1).padStart(2, '0')}-${Math.floor(1000 + Math.random() * 9000)}`; + const orderNo = "ORD-" + new Date().getFullYear() + String(new Date().getMonth() + 1).padStart(2, "0") + "-" + Math.floor(1000 + Math.random() * 9000); let totalAmount = 0; let subTotal = 0; let taxTotal = 0; - const baseTaxCode = await this.resolveTaxCode(companyId, taxCodeId); + const baseTaxCode = await this.taxService.resolveTaxCode( + companyId, + taxCodeId, + { operatorId: userId, entity: "Order", entityId: "new" }, + ); + const orderItems = await Promise.all( (items ?? []).map(async (item) => { const resolvedTaxCode = item.taxCodeId - ? await this.resolveTaxCode(companyId, item.taxCodeId) + ? await this.taxService.resolveTaxCode(companyId, item.taxCodeId) : baseTaxCode; const lineBase = item.quantity * item.unitPrice; - const breakdown = this.calcTaxBreakdown( + const breakdown = this.taxService.calcTaxBreakdown( lineBase, resolvedTaxCode.rate, resolvedTaxCode.isTaxInclusive, + resolvedTaxCode.taxNature, ); totalAmount += breakdown.total; @@ -125,7 +76,7 @@ export class OrdersService { totalPrice: breakdown.total, subTotal: breakdown.subTotal, taxAmount: breakdown.taxAmount, - taxRate: Number(resolvedTaxCode.rate ?? 0), + taxRate: breakdown.taxRate, taxCodeId: resolvedTaxCode.id ?? null, }; }), @@ -137,10 +88,10 @@ export class OrdersService { companyId, salesId: userId, partnerId, - status: 'DRAFT', + status: "DRAFT", totalAmount, - subTotal: this.round2(subTotal), - taxTotal: this.round2(taxTotal), + subTotal: this.taxService.round2(subTotal), + taxTotal: this.taxService.round2(taxTotal), taxCodeId: baseTaxCode.id ?? null, aiSummary, expectedDate: expectedDate ? new Date(expectedDate) : null, @@ -156,8 +107,8 @@ export class OrdersService { }); await this.eventQueueService.publish({ - eventName: 'order.created', - idempotencyKey: `order_created:${created.id}`, + eventName: "order.created", + idempotencyKey: "order_created:" + created.id, companyId, payload: { orderId: created.id, @@ -183,8 +134,8 @@ export class OrdersService { if (status) where.status = status; if (search) { where.OR = [ - { orderNo: { contains: search, mode: 'insensitive' } }, - { partner: { name: { contains: search, mode: 'insensitive' } } }, + { orderNo: { contains: search, mode: "insensitive" } }, + { partner: { name: { contains: search, mode: "insensitive" } } }, ]; } @@ -195,7 +146,7 @@ export class OrdersService { partner: true, salesPerson: { select: { id: true, name: true } }, }, - orderBy: { createdAt: 'desc' }, + orderBy: { createdAt: "desc" }, skip: (page - 1) * limit, take: limit, }), @@ -220,7 +171,7 @@ export class OrdersService { }, }, }); - if (!order) throw new NotFoundException('该订单不存在或您无权查看'); + if (!order) throw new NotFoundException("该订单不存在或您无权查看"); return order; } @@ -228,9 +179,9 @@ export class OrdersService { const order = await this.prisma.order.findFirst({ where: { id: orderId, companyId }, }); - if (!order) throw new NotFoundException('订单不存在或无权操作'); - if (order.status !== 'DRAFT') - throw new BadRequestException('只能删除草稿状态的订单'); + if (!order) throw new NotFoundException("订单不存在或无权操作"); + if (order.status !== "DRAFT") + throw new BadRequestException("只能删除草稿状态的订单"); await this.prisma.orderItem.deleteMany({ where: { orderId } }); return this.prisma.order.delete({ where: { id: orderId } }); @@ -243,16 +194,16 @@ export class OrdersService { }); if (!order) { - throw new NotFoundException('该订单不存在或您无权查看'); + throw new NotFoundException("该订单不存在或您无权查看"); } const logs = await this.prisma.auditLog.findMany({ where: { companyId, - entity: { in: ['order', 'sale_order'] }, + entity: { in: ["order", "sale_order"] }, entityId: orderId, }, - orderBy: { createdAt: 'desc' }, + orderBy: { createdAt: "desc" }, include: { user: { select: { id: true, name: true, email: true }, @@ -273,4 +224,4 @@ export class OrdersService { })), }; } -} +} \ No newline at end of file diff --git a/run_fix.js b/run_fix.js deleted file mode 100644 index 0420780..0000000 --- a/run_fix.js +++ /dev/null @@ -1,42 +0,0 @@ -const fs = require('fs'); -const filePath = 'F:/enterprise-erp/apps/web/src/components/core/DynamicView.tsx'; -let content = fs.readFileSync(filePath, 'utf-8'); - -if (!content.includes('import { Sheet }')) { - content = content.replace("import { FormEngine } from './FormEngine';", "import { FormEngine } from './FormEngine';\nimport { Sheet } from '../ui/Sheet';"); -} - -content = content.replace("type ViewMode = 'list' | 'kanban' | 'form';", "type ViewMode = 'list' | 'kanban';"); - -if (!content.includes('const [isFormOpen, setIsFormOpen]')) { - content = content.replace("const [mode, setMode] = useState('list');", "const [mode, setMode] = useState('list');\n const [isFormOpen, setIsFormOpen] = useState(false);"); -} - -content = content.replace( - /onRowClick=\{\(row\) => \{\s*setSelected\(row\);\s*setMode\('form'\);\s*\}\}/g, - `onRowClick={(row) => {\n setSelected(row);\n setIsFormOpen(true);\n }}` -); - -content = content.replace(/onCardClick=\{setSelected\}/g, `onCardClick={(row) => { setSelected(row); setIsFormOpen(true); }}`); - -content = content.replace( - /\}\s+active=\{mode === 'form'\}\s+onClick=\{\(\) => \{\s+setMode\('form'\);\s+setSelected\(initialFormValue\);\s+\}\}\s+label="[^"]+"\s+\/>/g, - `}\n active={isFormOpen}\n onClick={() => {\n setSelected(initialFormValue);\n setIsFormOpen(true);\n }}\n label="新建 / 编辑"\n />` -); - -content = content.replace(/if \(mode === 'form'\) \{/g, `if (isFormOpen) {`); -content = content.replace(/if \(mode !== 'form' \|\| !selectedId\) \{/g, `if (!isFormOpen || !selectedId) {`); -content = content.replace(/setMode\('form'\);/g, `setIsFormOpen(true);`); - -content = content.replace( - /\{mode === 'form' \? \(/g, - ` setIsFormOpen(false)}\n title={selected && selected.id ? \`编辑 \${activeTitle}\` : \`新建 \${activeTitle}\`}\n widthClassName="w-[min(1000px,95vw)]"\n >` -); - -content = content.replace( - / <\/aside>\n <\/div>\n \) : null\}/g, - ` \n
\n ` -); - -fs.writeFileSync(filePath, content, 'utf-8'); -console.log('Done!'); diff --git a/run_fix2.js b/run_fix2.js deleted file mode 100644 index 83b34bb..0000000 --- a/run_fix2.js +++ /dev/null @@ -1,13 +0,0 @@ -const fs = require('fs'); -const filePath = 'F:/enterprise-erp/apps/web/src/components/core/DynamicView.tsx'; -let content = fs.readFileSync(filePath, 'utf-8'); - -// Use precise match to replace that trailing block -const oldStr = / <\/aside>\s+<\/div>\s+\) : null\}/; -if (oldStr.test(content)) { - content = content.replace(oldStr, ' \n \n '); - fs.writeFileSync(filePath, content, 'utf-8'); - console.log('Fixed ending'); -} else { - console.log('Not found'); -} diff --git a/run_fix_workflow.js b/run_fix_workflow.js deleted file mode 100644 index 0db4989..0000000 --- a/run_fix_workflow.js +++ /dev/null @@ -1,190 +0,0 @@ -const fs = require('fs'); -const path = 'F:/enterprise-erp/apps/api/src/core/workflow/workflow.service.ts'; -const content = `import { - BadRequestException, - ConflictException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { PrismaService } from '../../prisma/prisma.service'; - -interface WorkflowTargetConfig { - delegate: string; - statusField: string; - companyField?: string; -} - -const WORKFLOW_TARGETS: Record = { - order: { delegate: 'order', statusField: 'status', companyField: 'companyId' }, - workOrder: { delegate: 'workOrder', statusField: 'status', companyField: 'companyId' }, - invoice: { delegate: 'invoice', statusField: 'status', companyField: 'companyId' }, -}; - -const EVENT_MODEL_ALIASES: Record = { - order: 'sale_order', -}; - -const ACTION_EVENT_ALIASES: Record = { - submit: 'confirmed', - start_production: 'in_production', - ship: 'shipped', - complete: 'completed', - cancel: 'cancelled', -}; - -@Injectable() -export class WorkflowService { - constructor( - private readonly prisma: PrismaService, - private readonly eventEmitter: EventEmitter2, - ) {} - - async transition( - modelName: string, - recordId: string, - action: string, - companyId: string, - operatorId: string, - note?: string, - extraData?: Record, - ) { - const normalizedModel = this.normalizeModelName(modelName); - const target = WORKFLOW_TARGETS[normalizedModel]; - - if (!target) { - throw new BadRequestException(\`模型 \${modelName} 暂不支持 workflow\`); - } - - const workflow = await this.prisma.workflow.findFirst({ - where: { - modelName: normalizedModel, - isActive: true, - OR: [{ companyId }, { companyId: null }], - }, - include: { - transitions: { - include: { fromState: true, toState: true }, - }, - }, - orderBy: [{ companyId: 'desc' }], - }); - - if (!workflow) { - throw new NotFoundException(\`未找到模型 \${modelName} 的 workflow 定义\`); - } - - const whereCondition = target.companyField - ? { id: recordId, [target.companyField]: companyId } - : { id: recordId }; - - const { updatedRecord, matchedTransition } = await this.prisma.$transaction(async (tx) => { - const txAny = tx as unknown as Record; - const txDelegate = txAny[target.delegate]; - - if (!txDelegate) { - throw new BadRequestException(\`模型 \${modelName} delegate 不存在\`); - } - - const record = await txDelegate.findFirst({ where: whereCondition }); - if (!record) { - throw new NotFoundException('目标业务单据不存在或无权限访问'); - } - - const currentState = record[target.statusField]; - const matched = workflow.transitions.find( - (item: any) => item.action === action && item.fromState.value === currentState, - ); - - if (!matched) { - throw new BadRequestException( - \`单据当前状态为 [\${currentState}],不支持 [\${action}] 动作。操作已被拒绝。\`, - ); - } - - const updateResultParams: any = { id: recordId }; - updateResultParams[target.statusField] = currentState; - - const updateResult = await txDelegate.updateMany({ - where: updateResultParams, - data: { [target.statusField]: matched.toState.value }, - }); - - if (updateResult.count === 0) { - throw new ConflictException('状态流转失败:该单据已在其他地方被修改过了,请刷新重试!'); - } - - const updatedRecordLocal = await txDelegate.findUnique({ where: { id: recordId } }); - - await tx.auditLog.create({ - data: { - userId: operatorId, - action: 'WORKFLOW_TRANSITION', - entity: modelName, - entityId: recordId, - details: JSON.parse( - JSON.stringify({ - workflowId: workflow.id, - action, - from: currentState, - to: matched.toState.value, - note, - data: extraData ?? {}, - }), - ), - companyId: companyId || 'system', - }, - }); - - return { updatedRecord: updatedRecordLocal, matchedTransition: matched }; - }); - - const eventModel = EVENT_MODEL_ALIASES[normalizedModel] ?? normalizedModel; - const toEvent = this.toEventKey(matchedTransition.toState.value); - const actionEvent = ACTION_EVENT_ALIASES[action] ?? toEvent; - - const payload = { - modelName: normalizedModel, - eventModel, - recordId, - workflowId: workflow.id, - companyId, - operatorId, - action, - from: matchedTransition.fromState.value, - to: matchedTransition.toState.value, - note, - data: extraData ?? {}, - record: updatedRecord, - }; - - this.eventEmitter.emit(\`workflow.action.\${eventModel}.\${toEvent}\`, payload); - if (actionEvent !== toEvent) { - this.eventEmitter.emit(\`workflow.action.\${eventModel}.\${actionEvent}\`, payload); - } - - return { - modelName: normalizedModel, - recordId, - action, - from: matchedTransition.fromState.value, - to: matchedTransition.toState.value, - data: updatedRecord, - }; - } - - private normalizeModelName(modelName: string) { - const normalized = modelName?.trim(); - if (!normalized) { - throw new BadRequestException('modelName 不能为空'); - } - if (normalized === 'workorder' || normalized === 'workOrder') return 'workOrder'; - return normalized; - } - - private toEventKey(value: string) { - return value.trim().toLowerCase().replace(/\\s+/g, '_'); - } -} -`; -fs.writeFileSync(path, content, 'utf8'); From e2208c18da2cc1edf33a033a5780e03869332894 Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Tue, 5 May 2026 15:04:28 +0800 Subject: [PATCH 08/54] feat(orders): add shipment & stock posting display in sale order detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /orders/:id/stock-transactions API endpoint - Add getStockTransactions service method querying InventoryTransaction by referenceNo pattern (SALE-SHIP-{orderNo}, REVERSE-SHIP-{orderNo}) - Add '物流发货' tab in SaleOrderDrawer showing StockPickings list and stock posting timeline - Create ShipmentsTable component: type badges, material, qty, batch, source/dest locations, referenceNo - Create ShipmentTimeline component: chronological posting flow with emoji type indicators --- apps/api/src/orders/orders.controller.ts | 9 +++ apps/api/src/orders/orders.service.ts | 55 ++++++++++++++++ .../src/components/sales/SaleOrderDrawer.tsx | 51 +++++++++++++- .../src/components/sales/ShipmentTimeline.tsx | 33 ++++++++++ .../src/components/sales/ShipmentsTable.tsx | 66 +++++++++++++++++++ 5 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/sales/ShipmentTimeline.tsx create mode 100644 apps/web/src/components/sales/ShipmentsTable.tsx diff --git a/apps/api/src/orders/orders.controller.ts b/apps/api/src/orders/orders.controller.ts index d553247..1be2db1 100644 --- a/apps/api/src/orders/orders.controller.ts +++ b/apps/api/src/orders/orders.controller.ts @@ -73,6 +73,15 @@ export class OrdersController { return this.ordersService.getOrderById(orderId, companyId); } + @Get(':id/stock-transactions') + @ApiOperation({ summary: '获取订单关联的库存过账与发货记录' }) + async getOrderStockTransactions( + @Param('id') orderId: string, + @CurrentCompany() companyId: string, + ) { + return this.ordersService.getOrderStockTransactions(orderId, companyId); + } + @Get(':id/timeline') @ApiOperation({ summary: '获取订单时间线(审计与流转记录)' }) async getOrderTimeline( diff --git a/apps/api/src/orders/orders.service.ts b/apps/api/src/orders/orders.service.ts index 27b7179..b3f93b1 100644 --- a/apps/api/src/orders/orders.service.ts +++ b/apps/api/src/orders/orders.service.ts @@ -187,6 +187,61 @@ export class OrdersService { return this.prisma.order.delete({ where: { id: orderId } }); } + async getOrderStockTransactions(orderId: string, companyId: string) { + const order = await this.prisma.order.findFirst({ + where: { id: orderId, companyId }, + select: { id: true, orderNo: true }, + }); + + if (!order) { + throw new NotFoundException('该订单不存在或您无权查看'); + } + + // 库存过账时 referenceNo 格式: SALE-SHIP-{orderNo} 或 REVERSE-SHIP-{orderNo} + const referencePatterns = [ + `SALE-SHIP-${order.orderNo}`, + `REVERSE-SHIP-${order.orderNo}`, + order.orderNo, + ]; + + const transactions = await this.prisma.inventoryTransaction.findMany({ + where: { + companyId, + referenceNo: { in: referencePatterns }, + }, + include: { + material: { select: { id: true, sku: true, name: true, unit: true } }, + sourceLocation: { + select: { id: true, name: true, code: true }, + include: { warehouse: { select: { id: true, name: true } } }, + }, + destLocation: { + select: { id: true, name: true, code: true }, + include: { warehouse: { select: { id: true, name: true } } }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return { + orderId: order.id, + orderNo: order.orderNo, + transactions: transactions.map((tx) => ({ + id: tx.id, + type: tx.type, + materialId: tx.materialId, + material: tx.material, + quantity: tx.quantity, + batchNo: tx.batchNo, + referenceNo: tx.referenceNo, + note: tx.note, + sourceLocation: tx.sourceLocation, + destLocation: tx.destLocation, + createdAt: tx.createdAt, + })), + }; + } + async getOrderTimeline(orderId: string, companyId: string) { const order = await this.prisma.order.findFirst({ where: { id: orderId, companyId }, diff --git a/apps/web/src/components/sales/SaleOrderDrawer.tsx b/apps/web/src/components/sales/SaleOrderDrawer.tsx index 70e9010..420a09c 100644 --- a/apps/web/src/components/sales/SaleOrderDrawer.tsx +++ b/apps/web/src/components/sales/SaleOrderDrawer.tsx @@ -4,7 +4,9 @@ import { useEffect, useMemo, useState } from 'react'; import { Sheet } from '@/components/ui/Sheet'; import { DataGrid } from '@/components/ui/data-grid/DataGrid'; import api from '@/lib/api'; -import { CheckCircle2, Save, Activity, Layers, Info, Loader2 } from 'lucide-react'; +import { CheckCircle2, Save, Activity, Layers, Info, Loader2, Truck } from 'lucide-react'; +import { ShipmentsTable } from './ShipmentsTable'; +import { ShipmentTimeline } from './ShipmentTimeline'; import type { ColumnDef } from '@tanstack/react-table'; import toast from 'react-hot-toast'; @@ -39,6 +41,20 @@ interface TimelineEvent { }; } +export interface StockTransaction { + id: string; + type: string; + materialId: string; + material?: { id: string; sku: string; name: string; unit: string }; + quantity: number; + batchNo?: string | null; + referenceNo?: string | null; + note?: string | null; + sourceLocation?: { id: string; name: string; code?: string | null; warehouse?: { id: string; name: string } } | null; + destLocation?: { id: string; name: string; code?: string | null; warehouse?: { id: string; name: string } } | null; + createdAt: string; +} + interface SaleOrderFormProps { open: boolean; onClose: () => void; @@ -46,7 +62,7 @@ interface SaleOrderFormProps { onSaved?: () => void; } -type TabType = 'LINES' | 'INFO' | 'CHATTER'; +type TabType = 'LINES' | 'INFO' | 'SHIPMENTS' | 'CHATTER'; export function SaleOrderDrawer({ open, onClose, orderId, onSaved }: SaleOrderFormProps) { const [activeTab, setActiveTab] = useState('LINES'); @@ -64,6 +80,7 @@ export function SaleOrderDrawer({ open, onClose, orderId, onSaved }: SaleOrderFo const [partners, setPartners] = useState([]); const [products, setProducts] = useState([]); const [timeline, setTimeline] = useState([]); + const [stockTransactions, setStockTransactions] = useState([]); const [lines, setLines] = useState([]); const [selectedLineIds, setSelectedLineIds] = useState([]); @@ -125,6 +142,7 @@ export function SaleOrderDrawer({ open, onClose, orderId, onSaved }: SaleOrderFo setOrderDate(new Date().toISOString().slice(0, 10)); setNotes(''); setTimeline([]); + setStockTransactions([]); setSelectedLineIds([]); setLines([ { @@ -165,6 +183,13 @@ export function SaleOrderDrawer({ open, onClose, orderId, onSaved }: SaleOrderFo const timelineRes = await api.get<{ events: TimelineEvent[] }>(`/orders/${id}/timeline`); setTimeline(timelineRes.data?.events ?? []); + + try { + const stockRes = await api.get<{ transactions: StockTransaction[] }>(`/orders/${id}/stock-transactions`); + setStockTransactions(stockRes.data?.transactions ?? []); + } catch { + setStockTransactions([]); + } }; useEffect(() => { @@ -508,6 +533,7 @@ export function SaleOrderDrawer({ open, onClose, orderId, onSaved }: SaleOrderFo {[ { id: 'LINES', label: '商品明细', icon: Layers }, { id: 'INFO', label: '开票与物流', icon: Info }, + { id: 'SHIPMENTS', label: '物流发货', icon: Truck }, { id: 'CHATTER', label: '操作台账', icon: Activity } ].map(tab => ( @@ -402,9 +404,9 @@ export default function InventoryPage() { {loc.locationName} - ({loc.rows.length} 种物料 + ({loc.rows.length} 种物? {loc.lowCount > 0 && ( - , {loc.lowCount} 低库存 + , {loc.lowCount} 低库?/span> )}) @@ -430,7 +432,7 @@ export default function InventoryPage() {
{row.batchCount}
{row.isLow ? ( - 低库存 + 低库?/span> ) : ( 正常 )} @@ -447,7 +449,7 @@ export default function InventoryPage() {
- 共 {filteredRows.length} 条库存明细 · 单击仓库/库位行展开/折叠 + ?{filteredRows.length} 条库存明?· 单击仓库/库位行展开/折叠
); diff --git a/apps/web/src/app/dashboard/inventory/receiving/page.tsx b/apps/web/src/app/dashboard/inventory/receiving/page.tsx new file mode 100644 index 0000000..0b1368c --- /dev/null +++ b/apps/web/src/app/dashboard/inventory/receiving/page.tsx @@ -0,0 +1,517 @@ +'use client'; + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import api from '@/lib/api'; +import { + Package, + Scan, + Search, + CheckCircle2, + Loader2, + Truck, + MapPin, + Hash, + AlertTriangle, + ChevronDown, +} from 'lucide-react'; +import toast from 'react-hot-toast'; + +interface Material { + id: string; + sku: string; + name: string; + unit: string; +} + +interface PurchaseOrderItem { + id: string; + materialId: string; + quantity: number; + receivedQty: number; + remainingQty: number; + unitPrice: number; + material: Material; +} + +interface Supplier { + id: string; + name: string; +} + +interface PurchaseOrder { + id: string; + orderNo: string; + status: string; + supplier: Supplier; + items: PurchaseOrderItem[]; + createdAt: string; +} + +interface Warehouse { + id: string; + name: string; +} + +interface StockLocation { + id: string; + name: string; + warehouseId: string | null; +} + +function StatusBadge({ status }: { status: string }) { + const map: Record = { + CONFIRMED: { label: 'ȷ', cls: 'erp-badge--info' }, + PARTIALLY_RECEIVED: { label: 'ջ', cls: 'erp-badge--pending' }, + RECEIVED: { label: 'ջ', cls: 'erp-badge--success' }, + }; + const s = map[status] ?? { label: status, cls: '' }; + return {s.label}; +} +export default function ReceivingPage() { + const [orders, setOrders] = useState([]); + const [locations, setLocations] = useState([]); + const [warehouses, setWarehouses] = useState([]); + const [loading, setLoading] = useState(true); + + const [selectedOrderId, setSelectedOrderId] = useState(''); + const [selectedItemId, setSelectedItemId] = useState(''); + const [selectedLocationId, setSelectedLocationId] = useState(''); + const [selectedWarehouseId, setSelectedWarehouseId] = useState(''); + + const [quantity, setQuantity] = useState(''); + const [batchNo, setBatchNo] = useState(''); + const [note, setNote] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const [scanMode, setScanMode] = useState(false); + const [scanBuffer, setScanBuffer] = useState(''); + const scanInputRef = useRef(null); + + const fetchOrders = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/purchase/orders/pending'); + setOrders(res.data ?? []); + } catch { + toast.error('زɹʧ'); + } finally { + setLoading(false); + } + }, []); + + const fetchLocations = useCallback(async () => { + try { + const [whRes, locRes] = await Promise.all([ + api.get('/inventory/warehouses'), + api.get('/inventory/locations'), + ]); + setWarehouses(whRes.data ?? []); + setLocations(locRes.data ?? []); + } catch { + toast.error('ؿλϢʧ'); + } + }, []); + + useEffect(() => { + void fetchOrders(); + void fetchLocations(); + }, [fetchOrders, fetchLocations]); + + const selectedOrder = useMemo( + () => orders.find((o) => o.id === selectedOrderId), + [orders, selectedOrderId], + ); + + const selectedItem = useMemo( + () => selectedOrder?.items.find((i) => i.id === selectedItemId), + [selectedOrder, selectedItemId], + ); + + const filteredLocations = useMemo(() => { + if (!selectedWarehouseId) return locations; + return locations.filter((l) => l.warehouseId === selectedWarehouseId); + }, [locations, selectedWarehouseId]); + + const qtyError = useMemo(() => { + if (!quantity || !selectedItem) return null; + const num = Number(quantity); + if (isNaN(num) || num <= 0) return ' 0'; + if (num > selectedItem.remainingQty) return `ջ (${selectedItem.remainingQty})`; + return null; + }, [quantity, selectedItem]); + + const isValid = !!selectedOrderId && !!selectedItemId && !!selectedLocationId && !!quantity && !qtyError; + const handleSubmit = async () => { + if (!isValid || !selectedOrder || !selectedItem) return; + setSubmitting(true); + try { + const payload = { + purchaseOrderId: selectedOrder.id, + itemId: selectedItem.id, + quantity: Number(quantity), + destLocationId: selectedLocationId, + batchNo: batchNo.trim() || undefined, + note: note.trim() || undefined, + }; + const res = await api.post('/purchase/orders/receive', payload); + toast.success(res.data?.message ?? 'ջɹ'); + setQuantity(''); + setBatchNo(''); + setNote(''); + setSelectedItemId(''); + await fetchOrders(); + } catch (err: unknown) { + const msg = + err && typeof err === 'object' && 'response' in err + ? String((err as { response: { data?: { message?: string } } }).response?.data?.message ?? 'ջʧ') + : 'ջʧ'; + toast.error(msg); + } finally { + setSubmitting(false); + } + }; + + const handleScan = async (sku: string) => { + if (!sku.trim() || !selectedLocationId) { + toast.error('ѡĿλ'); + return; + } + setSubmitting(true); + try { + const payload = { + materialSku: sku.trim(), + quantity: 1, + destLocationId: selectedLocationId, + batchNo: batchNo.trim() || undefined, + }; + const res = await api.post('/purchase/orders/scan-receive', payload); + toast.success(res.data?.message ?? 'ɨջɹ'); + await fetchOrders(); + } catch (err: unknown) { + const msg = + err && typeof err === 'object' && 'response' in err + ? String((err as { response: { data?: { message?: string } } }).response?.data?.message ?? 'ɨջʧ') + : 'ɨջʧ'; + toast.error(msg); + } finally { + setSubmitting(false); + setScanBuffer(''); + scanInputRef.current?.focus(); + } + }; + + return ( +
+
+
+

+ + ջִ +

+

+ ѡɹλκջ⡣֧ɨǹջ +

+
+
+ + +
+
+ {scanMode && ( +
+
+ + ɨǹջģʽ C ɨԶƥɹջ 1 +
+
+ setScanBuffer(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleScan(scanBuffer); + }} + placeholder="۽˴Ȼɨ..." + className="h-9 flex-1 rounded-lg border border-blue-300 bg-white px-3 text-sm outline-none focus:ring-2 focus:ring-blue-200" + /> + +
+ {!selectedLocationId && ( +

+ + ·ѡĿλɨ +

+ )} +
+ )} + +
+
+

+ + ջϢ +

+ +
+ +
+ + +
+
+ + {selectedOrder && ( +
+ +
+ + +
+
+ )} + + {selectedItem && ( +
+
+ ɹ + {selectedItem.quantity} {selectedItem.material.unit} +
+
+ ջ + {selectedItem.receivedQty} {selectedItem.material.unit} +
+
+ ջ + {selectedItem.remainingQty} {selectedItem.material.unit} +
+
+ )} +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ + setBatchNo(e.target.value)} + placeholder="ɨκţԶ" + className="h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm outline-none focus:ring-2 focus:ring-blue-100" + /> +
+ +
+ + setQuantity(e.target.value)} + placeholder={selectedItem ? ` ${selectedItem.remainingQty}` : ''} + min="0.01" + max={selectedItem?.remainingQty} + step="0.01" + className={`h-10 w-full rounded-lg border bg-white px-3 text-sm outline-none focus:ring-2 ${ + qtyError + ? 'border-red-300 focus:ring-red-100' + : 'border-slate-200 focus:ring-blue-100' + }`} + /> + {qtyError && ( +

+ + {qtyError} +

+ )} +
+ +
+ +