修改prisma适配原有表

This commit is contained in:
feie9456 2025-11-14 17:38:27 +08:00
parent ce4476fd4a
commit f6aab37d25
14 changed files with 330 additions and 301 deletions

View File

@ -8,7 +8,7 @@ import { prisma } from './src/utils/prisma.js';
import { createOpenAPIDocument } from './src/openapi/registry.js';
import requestsRouter from './src/routes/requests.routes.js';
import itemsRouter from './src/routes/items.routes.js';
import catalogRouter from './src/routes/catalog.routes.js';
import goodsRouter from './src/routes/goods.routes.js';
// ---------- App ----------
const app = express();
@ -21,7 +21,7 @@ app.get('/health', (_req, res) => res.json({ ok: true, time: new Date().toISOStr
// ---------- Routes ----------
app.use('/api/consumable-temp-requests', requestsRouter);
app.use('/api/consumable-temp-items', itemsRouter);
app.use('/api/consumable-temp-catalog', catalogRouter);
app.use('/api/consumable-goods', goodsRouter);
// ---------- OpenAPI Documentation ----------
const openApiDocument = createOpenAPIDocument();

View File

@ -0,0 +1,4 @@
-- This migration is a placeholder because the tables already exist in the database
-- The tables tb_consumable_goods and tb_consumable_sku were created outside of Prisma
-- No actual schema changes needed

View File

@ -0,0 +1,8 @@
-- DropForeignKey
ALTER TABLE `_易耗品条目` DROP FOREIGN KEY `_易耗品条目_catalogId_fkey`;
-- AlterTable
ALTER TABLE `_易耗品条目` DROP COLUMN `catalogId`;
-- DropTable
DROP TABLE ``;

View File

@ -10,78 +10,92 @@ datasource db {
/// 申请单(“易耗品临时申请”)
model v2_ConsumableTempRequest {
id String @id @default(cuid())
applicantName String? // 申请人姓名(可选)
applicantPhone String // 申请人手机号
department String? // 部门(可选)
reason String? // 申请事由(可选)
neededAt DateTime? // 期望领用时间(可选)
status v2_ConsumableTempRequestStatus @default(PENDING)
approverId String? // 审批人可选员工ID/账号)
approvedAt DateTime? // 审批时间(可选)
remark String? // 备注(可选)
// 一对多:申请单包含多条物料明细
id String @id @default(cuid())
applicantName String?
applicantPhone String
department String?
reason String?
neededAt DateTime?
status v2_ConsumableTempRequestStatus @default(PENDING)
approverId String?
approvedAt DateTime?
remark String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items v2_ConsumableTempRequestItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([applicantPhone, createdAt])
@@index([status, createdAt])
// 如需将表名映射为中文(或你既有库的真实表名),取消下一行注释:
@@map("易耗品临时申请")
}
/// 申请明细(易耗品条目)
model v2_ConsumableTempRequestItem {
id String @id @default(cuid())
requestId String
request v2_ConsumableTempRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
name String // 物料名称(如“打印纸”)
spec String? // 规格/型号如“A4 70g”
quantity Int // 数量
unit String? // 单位(如“包/箱/个”)
estimatedUnitCost Decimal? // 预估单价(可选)
remark String? // 明细备注(可选)
// 如果你有物料目录表,可在此做可选关联(下面给了 Catalog 模型)
catalogId String?
catalog v2_ConsumableTempCatalog? @relation(fields: [catalogId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
requestId String
name String
spec String?
quantity Int
unit String?
estimatedUnitCost Decimal?
remark String?
catalogId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
request v2_ConsumableTempRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
@@index([requestId])
@@index([name])
// 如需把表名映射为既有库命名,取消注释并替换:
@@index([catalogId], map: "易耗品临时申请_易耗品条目_catalogId_fkey")
@@map("易耗品临时申请_易耗品条目")
}
/// 可选:物料目录(如你已有标准物料清单,便于下拉选择)
model v2_ConsumableTempCatalog {
id String @id @default(cuid())
name String
spec String?
unit String?
isActive Boolean @default(true)
model tb_consumable_goods {
id BigInt @id @default(autoincrement())
goods_name String @db.VarChar(100)
category_id BigInt?
brand String? @db.VarChar(50)
status Int? @default(0) @db.TinyInt
create_time DateTime? @default(now()) @db.Timestamp(0)
update_time DateTime? @default(now()) @db.Timestamp(0)
delete_flag Boolean? @default(false)
hosp_id String? @db.VarChar(32)
// 反向关系:哪些申请明细引用了该条目录
requestItems v2_ConsumableTempRequestItem[]
// 关联 SKU
tb_consumable_sku tb_consumable_sku[]
@@unique([name, spec, unit])
@@index([isActive])
@@map("易耗品目录")
@@index([category_id], map: "idx_goods_category")
}
model tb_consumable_sku {
id BigInt @id @default(autoincrement())
goods_id BigInt
sku_code String? @unique(map: "uk_sku_code") @db.VarChar(50)
specification String? @db.VarChar(200)
barcode String @unique(map: "uk_sku_barcode") @db.VarChar(50)
supplier_id BigInt?
purchase_price Decimal? @db.Decimal(10, 2)
selling_price Decimal? @db.Decimal(10, 2)
unit String? @db.VarChar(20)
min_stock Int? @default(0)
max_stock Int?
status Int? @default(0) @db.TinyInt
create_time DateTime? @default(now()) @db.Timestamp(0)
update_time DateTime? @default(now()) @db.Timestamp(0)
delete_flag Boolean? @default(false)
hosp_id String? @db.VarChar(32)
// 关联商品
tb_consumable_goods tb_consumable_goods @relation(fields: [goods_id], references: [id])
@@index([goods_id], map: "idx_sku_goods")
@@index([supplier_id], map: "idx_sku_supplier")
}
enum v2_ConsumableTempRequestStatus {
PENDING // 待审批
APPROVED // 通过
REJECTED // 驳回
CANCELLED // 取消
COMPLETED // 已完成(已领用)
}
PENDING
APPROVED
REJECTED
CANCELLED
COMPLETED
}

View File

@ -1,146 +0,0 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { z } from 'zod';
import { catalogCreateSchema, catalogUpdateSchema, catalogResponseSchema } from '../schemas/catalog.schemas.js';
import { errorResponseSchema, paginatedResponseSchema } from '../schemas/common.js';
export function registerCatalogPaths(registry: OpenAPIRegistry) {
// POST /api/consumable-temp-catalog
registry.registerPath({
method: 'post',
path: '/api/consumable-temp-catalog',
tags: ['Consumable Catalog'],
summary: '创建耗材目录',
request: {
body: {
content: {
'application/json': {
schema: catalogCreateSchema
}
}
}
},
responses: {
201: {
description: '创建成功',
content: {
'application/json': {
schema: z.object({ data: catalogResponseSchema })
}
}
}
}
});
// GET /api/consumable-temp-catalog
registry.registerPath({
method: 'get',
path: '/api/consumable-temp-catalog',
tags: ['Consumable Catalog'],
summary: '查询耗材目录列表',
request: {
query: z.object({
page: z.string().optional().openapi({ description: '页码' }),
pageSize: z.string().optional().openapi({ description: '每页数量' }),
isActive: z.string().optional().openapi({ description: '是否激活' }),
q: z.string().optional().openapi({ description: '搜索关键词' })
})
},
responses: {
200: {
description: '查询成功',
content: {
'application/json': {
schema: paginatedResponseSchema(catalogResponseSchema)
}
}
}
}
});
// GET /api/consumable-temp-catalog/:id
registry.registerPath({
method: 'get',
path: '/api/consumable-temp-catalog/{id}',
tags: ['Consumable Catalog'],
summary: '获取耗材目录详情',
request: {
params: z.object({
id: z.string().openapi({ description: '目录ID' })
})
},
responses: {
200: {
description: '查询成功',
content: {
'application/json': {
schema: z.object({ data: catalogResponseSchema })
}
}
},
404: {
description: '未找到',
content: {
'application/json': {
schema: errorResponseSchema
}
}
}
}
});
// PUT /api/consumable-temp-catalog/:id
registry.registerPath({
method: 'put',
path: '/api/consumable-temp-catalog/{id}',
tags: ['Consumable Catalog'],
summary: '更新耗材目录',
request: {
params: z.object({
id: z.string().openapi({ description: '目录ID' })
}),
body: {
content: {
'application/json': {
schema: catalogUpdateSchema
}
}
}
},
responses: {
200: {
description: '更新成功',
content: {
'application/json': {
schema: z.object({ data: catalogResponseSchema })
}
}
},
404: {
description: '未找到',
content: {
'application/json': {
schema: errorResponseSchema
}
}
}
}
});
// DELETE /api/consumable-temp-catalog/:id
registry.registerPath({
method: 'delete',
path: '/api/consumable-temp-catalog/{id}',
tags: ['Consumable Catalog'],
summary: '停用耗材目录',
request: {
params: z.object({
id: z.string().openapi({ description: '目录ID' })
})
},
responses: {
204: {
description: '停用成功'
}
}
});
}

View File

@ -0,0 +1,67 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { z } from 'zod';
import { goodsWithSkuResponseSchema } from '../schemas/goods.schemas.js';
import { errorResponseSchema, paginatedResponseSchema } from '../schemas/common.js';
export function registerGoodsPaths(registry: OpenAPIRegistry) {
// GET /api/consumable-goods
registry.registerPath({
method: 'get',
path: '/api/consumable-goods',
tags: ['Consumable Goods'],
summary: '查询易耗品种类列表(含规格/价格)',
description: '联合查询商品和 SKU 信息,返回商品及其所有规格、价格等详细信息',
request: {
query: z.object({
page: z.string().optional().openapi({ description: '页码', example: '1' }),
pageSize: z.string().optional().openapi({ description: '每页数量', example: '20' }),
status: z.string().optional().openapi({ description: '状态0-正常1-停用', example: '0' }),
hospId: z.string().optional().openapi({ description: '医院ID' }),
categoryId: z.string().optional().openapi({ description: '分类ID' }),
q: z.string().optional().openapi({ description: '搜索关键词(商品名称)', example: '打印纸' })
})
},
responses: {
200: {
description: '查询成功',
content: {
'application/json': {
schema: paginatedResponseSchema(goodsWithSkuResponseSchema)
}
}
}
}
});
// GET /api/consumable-goods/:id
registry.registerPath({
method: 'get',
path: '/api/consumable-goods/{id}',
tags: ['Consumable Goods'],
summary: '获取易耗品种类详情(含规格/价格)',
description: '获取单个商品及其所有 SKU 规格、价格等详细信息',
request: {
params: z.object({
id: z.string().openapi({ description: '商品ID' })
})
},
responses: {
200: {
description: '查询成功',
content: {
'application/json': {
schema: z.object({ data: goodsWithSkuResponseSchema })
}
}
},
404: {
description: '未找到',
content: {
'application/json': {
schema: errorResponseSchema
}
}
}
}
});
}

View File

@ -7,15 +7,11 @@ import {
requestItemResponseSchema,
requestResponseSchema
} from '../schemas/request.schemas.js';
import {
catalogCreateSchema,
catalogUpdateSchema,
catalogResponseSchema
} from '../schemas/catalog.schemas.js';
import { goodsWithSkuResponseSchema, skuResponseSchema } from '../schemas/goods.schemas.js';
import { errorResponseSchema } from '../schemas/common.js';
import { registerRequestPaths } from './request.openapi.js';
import { registerItemPaths } from './item.openapi.js';
import { registerCatalogPaths } from './catalog.openapi.js';
import { registerGoodsPaths } from './goods.openapi.js';
export function createOpenAPIDocument() {
const registry = new OpenAPIRegistry();
@ -25,17 +21,16 @@ export function createOpenAPIDocument() {
registry.register('CreateRequest', createRequestSchema);
registry.register('UpdateRequest', updateRequestSchema);
registry.register('UpdateStatus', updateStatusSchema);
registry.register('CatalogCreate', catalogCreateSchema);
registry.register('CatalogUpdate', catalogUpdateSchema);
registry.register('RequestItemResponse', requestItemResponseSchema);
registry.register('RequestResponse', requestResponseSchema);
registry.register('CatalogResponse', catalogResponseSchema);
registry.register('SKUResponse', skuResponseSchema);
registry.register('GoodsWithSKUResponse', goodsWithSkuResponseSchema);
registry.register('ErrorResponse', errorResponseSchema);
// 注册路由
registerRequestPaths(registry);
registerItemPaths(registry);
registerCatalogPaths(registry);
registerGoodsPaths(registry);
// 生成文档
const generator = new OpenApiGeneratorV3(registry.definitions);
@ -44,7 +39,7 @@ export function createOpenAPIDocument() {
info: {
title: '智能配送耗材临时申请 API',
version: '1.0.0',
description: '智能配送系统的耗材临时申请管理 API支持申请创建、审批、物料明细管理和目录管理'
description: '智能配送系统的耗材临时申请管理 API支持申请创建、审批、物料明细管理和易耗品种类查询'
},
servers: [
{

View File

@ -1,50 +0,0 @@
import { Router } from 'express';
import { asyncHandler } from '../utils/asyncHandler.js';
import { prisma } from '../utils/prisma.js';
import { paginateParamsSchema } from '../schemas/common.js';
import { catalogCreateSchema, catalogUpdateSchema } from '../schemas/catalog.schemas.js';
const router = Router();
// Create catalog
router.post('/', asyncHandler(async (req, res) => {
const parsed = catalogCreateSchema.parse(req.body);
const data = await prisma.v2_ConsumableTempCatalog.create({ data: parsed });
res.status(201).json({ data });
}));
// List catalogs
router.get('/', asyncHandler(async (req, res) => {
const { page, pageSize } = paginateParamsSchema.parse({ page: req.query.page, pageSize: req.query.pageSize });
const where: any = {};
if (req.query.isActive !== undefined) where.isActive = req.query.isActive === 'true';
if (req.query.q) where.name = { contains: String(req.query.q) };
const [total, rows] = await Promise.all([
prisma.v2_ConsumableTempCatalog.count({ where }),
prisma.v2_ConsumableTempCatalog.findMany({ where, orderBy: { name: 'asc' }, skip: (page - 1) * pageSize, take: pageSize })
]);
res.json({ data: rows, meta: { total, page, pageSize } });
}));
// Get catalog detail
router.get('/:id', asyncHandler(async (req, res) => {
const data = await prisma.v2_ConsumableTempCatalog.findUnique({ where: { id: req.params.id } });
if (!data) return res.status(404).json({ error: 'Not Found' });
res.json({ data });
}));
// Update catalog
router.put('/:id', asyncHandler(async (req, res) => {
const parsed = catalogUpdateSchema.parse(req.body);
const data = await prisma.v2_ConsumableTempCatalog.update({ where: { id: req.params.id }, data: parsed }).catch(e => { if ((e as any).code === 'P2025') return null; throw e; });
if (!data) return res.status(404).json({ error: 'Not Found' });
res.json({ data });
}));
// Deactivate instead of delete
router.delete('/:id', asyncHandler(async (req, res) => {
await prisma.v2_ConsumableTempCatalog.update({ where: { id: req.params.id }, data: { isActive: false } }).catch(e => { if ((e as any).code === 'P2025') return null; throw e; });
res.status(204).end();
}));
export default router;

138
src/routes/goods.routes.ts Normal file
View File

@ -0,0 +1,138 @@
import { Router } from 'express';
import { asyncHandler } from '../utils/asyncHandler.js';
import { prisma } from '../utils/prisma.js';
import { paginateParamsSchema } from '../schemas/common.js';
const router = Router();
// List goods with SKU information (联合查询)
router.get('/', asyncHandler(async (req, res) => {
const { page, pageSize } = paginateParamsSchema.parse({
page: req.query.page,
pageSize: req.query.pageSize
});
// 构建查询条件
const where: any = {
delete_flag: false
};
// 过滤条件
if (req.query.status !== undefined) {
where.status = Number(req.query.status);
}
if (req.query.hospId) {
where.hosp_id = String(req.query.hospId);
}
if (req.query.categoryId) {
where.category_id = BigInt(String(req.query.categoryId));
}
if (req.query.q) {
where.goods_name = { contains: String(req.query.q) };
}
// 查询商品列表(带 SKU
const [total, goods] = await Promise.all([
prisma.tb_consumable_goods.count({ where }),
prisma.tb_consumable_goods.findMany({
where,
include: {
// 联合查询 SKU 信息
tb_consumable_sku: {
where: {
delete_flag: false
},
orderBy: {
sku_code: 'asc'
}
}
},
orderBy: {
create_time: 'desc'
},
skip: (page - 1) * pageSize,
take: pageSize
})
]);
// 转换数据格式
const data = goods.map(item => ({
id: Number(item.id),
goodsName: item.goods_name,
categoryId: item.category_id ? Number(item.category_id) : null,
brand: item.brand,
status: item.status,
hospId: item.hosp_id,
skus: item.tb_consumable_sku.map(sku => ({
id: Number(sku.id),
skuCode: sku.sku_code,
specification: sku.specification,
barcode: sku.barcode,
supplierId: sku.supplier_id ? Number(sku.supplier_id) : null,
purchasePrice: sku.purchase_price ? Number(sku.purchase_price) : null,
sellingPrice: sku.selling_price ? Number(sku.selling_price) : null,
unit: sku.unit,
minStock: sku.min_stock,
maxStock: sku.max_stock,
status: sku.status
}))
}));
res.json({
data,
meta: { total: Number(total), page, pageSize }
});
}));
// Get single goods with SKU detail
router.get('/:id', asyncHandler(async (req, res) => {
const goodsId = BigInt(req.params.id!);
const goods = await prisma.tb_consumable_goods.findUnique({
where: {
id: goodsId,
delete_flag: false
},
include: {
tb_consumable_sku: {
where: {
delete_flag: false
},
orderBy: {
sku_code: 'asc'
}
}
}
});
if (!goods) {
return res.status(404).json({ error: 'Not Found' });
}
// 转换数据格式
const data = {
id: Number(goods.id),
goodsName: goods.goods_name,
categoryId: goods.category_id ? Number(goods.category_id) : null,
brand: goods.brand,
status: goods.status,
hospId: goods.hosp_id,
skus: goods.tb_consumable_sku.map(sku => ({
id: Number(sku.id),
skuCode: sku.sku_code,
specification: sku.specification,
barcode: sku.barcode,
supplierId: sku.supplier_id ? Number(sku.supplier_id) : null,
purchasePrice: sku.purchase_price ? Number(sku.purchase_price) : null,
sellingPrice: sku.selling_price ? Number(sku.selling_price) : null,
unit: sku.unit,
minStock: sku.min_stock,
maxStock: sku.max_stock,
status: sku.status
}))
};
res.json({ data });
}));
export default router;

View File

@ -17,8 +17,7 @@ router.put('/:itemId', asyncHandler(async (req, res) => {
quantity: parsed.quantity,
unit: parsed.unit,
estimatedUnitCost: parsed.estimatedUnitCost !== undefined ? new Prisma.Decimal(parsed.estimatedUnitCost) : undefined,
remark: parsed.remark,
catalogId: parsed.catalogId
remark: parsed.remark
}
}).catch(e => { if ((e as any).code === 'P2025') return null; throw e; });
if (!item) return res.status(404).json({ error: 'Not Found' });

View File

@ -29,8 +29,7 @@ router.post('/', asyncHandler(async (req, res) => {
quantity: i.quantity,
unit: i.unit,
estimatedUnitCost: i.estimatedUnitCost !== undefined ? new Prisma.Decimal(i.estimatedUnitCost) : undefined,
remark: i.remark,
catalogId: i.catalogId ?? null
remark: i.remark
})) }
},
include: { items: true }
@ -109,8 +108,7 @@ router.put('/:id', asyncHandler(async (req, res) => {
quantity: item.quantity ?? undefined,
unit: item.unit,
estimatedUnitCost: item.estimatedUnitCost !== undefined ? new Prisma.Decimal(item.estimatedUnitCost) : undefined,
remark: item.remark,
catalogId: item.catalogId
remark: item.remark
}
});
} else {
@ -121,8 +119,7 @@ router.put('/:id', asyncHandler(async (req, res) => {
quantity: item.quantity,
unit: item.unit,
estimatedUnitCost: item.estimatedUnitCost !== undefined ? new Prisma.Decimal(item.estimatedUnitCost) : undefined,
remark: item.remark,
catalogId: item.catalogId
remark: item.remark
}});
}
}
@ -168,8 +165,7 @@ router.post('/:id/items', asyncHandler(async (req, res) => {
quantity: parsed.quantity,
unit: parsed.unit,
estimatedUnitCost: parsed.estimatedUnitCost !== undefined ? new Prisma.Decimal(parsed.estimatedUnitCost) : undefined,
remark: parsed.remark,
catalogId: parsed.catalogId ?? null
remark: parsed.remark
}});
res.status(201).json({ data: item });
}));

View File

@ -1,25 +0,0 @@
import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
extendZodWithOpenApi(z);
// ---------- Input Schemas ----------
export const catalogCreateSchema = z.object({
name: z.string().min(1).openapi({ example: '办公用品', description: '目录名称' }),
spec: z.string().optional().openapi({ example: '标准', description: '规格' }),
unit: z.string().optional().openapi({ example: '个', description: '单位' }),
isActive: z.boolean().optional().default(true).openapi({ description: '是否激活' })
});
export const catalogUpdateSchema = catalogCreateSchema.partial();
// ---------- Response Schemas ----------
export const catalogResponseSchema = z.object({
id: z.string().openapi({ description: '目录ID' }),
name: z.string().openapi({ description: '目录名称' }),
spec: z.string().nullable().openapi({ description: '规格' }),
unit: z.string().nullable().openapi({ description: '单位' }),
isActive: z.boolean().openapi({ description: '是否激活' }),
createdAt: z.string().openapi({ description: '创建时间' }),
updatedAt: z.string().openapi({ description: '更新时间' })
});

View File

@ -0,0 +1,30 @@
import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
extendZodWithOpenApi(z);
// ---------- SKU Schema (规格信息) ----------
export const skuResponseSchema = z.object({
id: z.number().openapi({ description: 'SKU ID' }),
skuCode: z.string().nullable().openapi({ description: 'SKU编码' }),
specification: z.string().nullable().openapi({ description: '规格型号', example: 'A4 70g' }),
barcode: z.string().openapi({ description: '条形码' }),
supplierId: z.number().nullable().openapi({ description: '供应商ID' }),
purchasePrice: z.number().nullable().openapi({ description: '采购价格', example: 25.50 }),
sellingPrice: z.number().nullable().openapi({ description: '销售价格', example: 30.00 }),
unit: z.string().nullable().openapi({ description: '单位', example: '包' }),
minStock: z.number().nullable().openapi({ description: '最小库存' }),
maxStock: z.number().nullable().openapi({ description: '最大库存' }),
status: z.number().nullable().openapi({ description: '状态0-正常1-停用' })
});
// ---------- Goods with SKU Schema (种类+规格) ----------
export const goodsWithSkuResponseSchema = z.object({
id: z.number().openapi({ description: '商品ID' }),
goodsName: z.string().openapi({ description: '商品名称', example: '打印纸' }),
categoryId: z.number().nullable().openapi({ description: '分类ID' }),
brand: z.string().nullable().openapi({ description: '品牌' }),
status: z.number().nullable().openapi({ description: '状态0-正常1-停用' }),
hospId: z.string().nullable().openapi({ description: '医院ID' }),
skus: z.array(skuResponseSchema).openapi({ description: 'SKU列表规格/价格等信息)' })
});

View File

@ -11,8 +11,7 @@ export const requestItemInputSchema = z.object({
quantity: z.number().int().positive().openapi({ example: 10, description: '数量' }),
unit: z.string().optional().openapi({ example: '箱', description: '单位' }),
estimatedUnitCost: z.number().positive().optional().openapi({ example: 25.5, description: '预估单价' }),
remark: z.string().optional().openapi({ description: '备注' }),
catalogId: z.string().optional().openapi({ description: '关联的目录ID' })
remark: z.string().optional().openapi({ description: '备注' })
});
export const createRequestSchema = z.object({