# MES PRD to Code — 项目规范规则集

> 本文件由 specpow 框架的 `extractRulesFromSchemaFiles` 自动加载。
> 所有以 `-` 开头的行会被提取为约束规则，通过 `{{rules}}` 注入到 AI instruction 中。
> 来源: 项目实际代码（BaseModel/ViewModel/ServiceImpl/Mapper XML/Vue 组件）+ DATABASE.md + JAVA.md + TESTING.md

## 数据库 DDL 规范

- 表名格式: `{模块简称}_{功能描述}`，小写下划线，模块前缀: BD/MM/WIP/FG/PM/QM/EM/MCC/AGV/RPT
- 子表命名: `{主表名}_DETAIL`，配置表: `{功能}_CONFIG`，历史表: `{功能}_H_T`
- 主键字段: `id NUMBER(19) NOT NULL`，使用雪花算法生成（IdType.ASSIGN_ID），禁止 Oracle SEQUENCE
- 多租户字段: `org_id NUMBER(19) NOT NULL`，仅在 DDL 中定义，不在 Entity 中声明（TenantLineInnerInterceptor 自动处理）
- 审计字段: `created_by NVARCHAR2(64)`, `created_date DATE DEFAULT SYSDATE`, `last_updated_by NVARCHAR2(64)`, `last_updated_date DATE DEFAULT SYSDATE`
- 字符串类型统一使用 NVARCHAR2，禁止使用 VARCHAR2 或 VARCHAR
- 数值类型: 主键用 NUMBER(19)，状态/标志用 NUMBER(2) 或 NUMBER(1)，数量/金额用 NUMBER(18,4)
- 日期类型: DATE，默认值 DEFAULT SYSDATE
- 扩展字段: ATTRIBUTE1~ATTRIBUTE5 NVARCHAR2(150)（预留扩展，按需添加）
- 表名字段名全部小写下划线命名（snake_case），Java 中自动映射为驼峰（camelCase）

## 数据库索引规范

- 主键索引命名: `{tableName}_PK`
- 普通索引命名: `{tableName}_N1` ~ `{tableName}_N5`
- 唯一索引命名: `{tableName}_U1` ~ `{tableName}_U5`
- 唯一索引必须以 org_id 为首列: `(org_id, 业务字段1, 业务字段2...)`
- 数据表空间: TABLESPACE MES_DATA_{WORKS|WMS|COM}（生产/仓储/通用）
- 索引表空间: USING INDEX TABLESPACE MES_IDX_{WORKS|WMS|COM}
- 主键约束: `CONSTRAINT {表名}_PK PRIMARY KEY (id) USING INDEX TABLESPACE {索引表空间}`
- 每张表和每个字段必须有 COMMENT ON 注释
- 禁止使用 SELECT *，必须明确列出字段名

## 后端 Entity 规范

- 必须继承 `com.twsz.mom.ds.model.BaseModel`（不是 com.twsz.mom.common.base.BaseModel）
- BaseModel 继承链: ViewModel → TimeModel → BaseModel，已提供 id/审计字段/查询字段
- BaseModel 已提供: id(Long, ASSIGN_ID, ToStringSerializer), createdBy, createdDate, lastUpdatedBy, lastUpdatedDate
- ViewModel 已提供: opt("1"=新增,"2"=修改,"3"=删除), columns, orderBy, fields, csv, rowNum — Entity 中禁止重复声明
- @EqualsAndHashCode(callSuper = true)（不是 false）
- @JsonInclude(JsonInclude.Include.NON_NULL)
- @TableName("表名")
- id 字段必须在 Entity 中显式声明: @TableId(type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class)（覆盖 BaseModel 声明以确保序列化）
- org_id 禁止在 Entity 中声明（TenantLineInnerInterceptor 自动注入）
- 每个业务字段使用 @ExcelProperty("中文名") 注解（支持导入导出）
- 非数据库字段使用 @TableField(exist = false) + @ExcelIgnore
- Long 类型字段暴露给前端时必须加 @JsonSerialize(using = ToStringSerializer.class)（防止 JS 精度丢失）
- 日期输出格式化: @JsonFormat(pattern = "yyyy/MM/dd HH:mm:ss") 或 @JsonSerialize(using = LocalDateTimeSerializer.class)
- 使用 Lombok: @Data, @EqualsAndHashCode, @Builder, @NoArgsConstructor, @AllArgsConstructor
- 编号字段使用 @AutoGenCode 注解（自动生成编号）
- 主子表的子表集合字段: @TableField(exist = false) private List<DetailEntity> details

## 后端 Controller 规范

- @RestController + @RequestMapping("/api/v1/entity-kebab-case")（含版本号 /api/v1/）
- 使用 @Autowired 或 @Resource 字段注入（项目惯例，非构造器注入）
- 统一返回 ResponseWrapper<T>（com.twsz.mom.core.common.ResponseWrapper）
- 标准方法: search(POST) / save(POST) / delete(DELETE) / list(POST) / export(POST)
- save 方法: id==null 时 insert，id!=null 时 update（Controller 层判断或 Service 层 saveModel）
- Controller 只做参数校验和结果封装，禁止写业务逻辑
- 禁止在 Controller 中 try-catch（全局异常处理器 @ControllerAdvice 统一处理）
- delete 接口: @DeleteMapping，接收 `Long[] ids`
- export 接口: 必须用 BaseExcelExportTemplate 匿名子类（实现 getTotal()、getData(start, size)，start 从 1 开始），调用 exportBatch(HttpServletResponse, Converter...) 写入响应；禁止 ExcelUtil.export()、禁止 EasyExcel.write()
- export 位置: 匿名子类封装在 ServiceImpl 导出方法中（如 entityService.export(entity, response)），Controller 只注入调用，禁止在 Controller 内写导出逻辑
- import 接口: 接收 MultipartFile，必须用 EasyExcel.read(inputStream, Entity.class, new BaseAnalysisEventListener<Entity>(batchSize) { handle(List<Entity>) }).sheet().doRead() 驱动；禁止 ExcelUtil.importExcel()、禁止 doReadSync() 裸读
- import 位置: 校验与入库逻辑写在 BaseAnalysisEventListener.handle(List<Entity>) 内，由 batchSize 控制分批；禁止在 handle() 内单条循环查询

## 后端 Service 规范

- 接口继承 IService<T>，实现类继承 ServiceImpl<Mapper, Entity> 并实现接口
- @Slf4j + @Service 注解
- 审计字段（createdBy/createdDate/lastUpdatedBy/lastUpdatedDate）由 MyBatis-Plus MetaObjectHandler 自动填充，禁止在代码中手动设置（包括 LambdaUpdateWrapper.set()）
- orgId 由 TenantLineInnerInterceptor 自动注入，禁止在代码中手动设置
- 禁止仅为设置审计字段而导入 ApplicationContextUtil——审计字段由自动填充处理
- saveModel 方法: @Transactional(rollbackFor = Exception.class)，id==null 调 insert，否则检查乐观锁后调 update
- 主子表保存模式: 先保存主表，再遍历明细列表按 opt 标记处理（1=新增, 2=修改, 3=删除）
- saveOrUpdateOrDelete 模式: 按 ViewModel.OPT_ADD/MODIFY/DELETE 分组，批量 saveBatch/updateBatchById/delete
- 乐观锁: 编辑时查库比对 lastUpdatedDate（withNano(0) 去掉纳秒），不匹配则 throw new BaseException
- 异常抛出: `throw new BaseException(AIHttpStatus.XXX.getCode(), AIHttpStatus.XXX.getMessage(), param1, param2)`
- 带参数占位: `throw new BaseException(AIHttpStatus.XXX.getCode(), AIHttpStatus.XXX.getMessage(), param1, param2)`
- 异常消息必须包含具体参数值，使用 `{0}`、`{1}` 占位符（如 "库位[{0}]不存在"），禁止笼统提示（如 "库位不存在"）
- Service 层业务校验失败必须抛 BaseException，禁止用 ResponseWrapper.ofFail/ofStatus 返回错误——全局异常处理器统一包装
- Service 方法返回类型: 成功用 ResponseWrapper<T> 或直接返回数据，失败一律抛异常
- AIHttpStatus 新增 code 规则: 取 AIHttpStatus 枚举中已有 code 最大值 + 1（maxCode + 1），禁止硬编码数字
- AIHttpStatus 枚举命名: 中文提示的缩写，如 "类型不能为空" → TYPE_NOT_EMPTY
- 禁止在 Service 中手动设置 orgId（多租户插件自动注入）
- 禁止在循环中执行数据库查询（N+1 问题），必须批量查询后内存处理
- 新增/修改操作后必须清除相关 Redis 缓存
- 使用 LambdaQueryWrapper / LambdaUpdateWrapper，禁止硬编码字符串字段名
- @Transactional 写在 ServiceImpl 方法上，不写在接口上

## 后端 Mapper XML 规范

- 使用 <sql id="EntityColumns"> 定义字段列表（t.前缀），支持 entity.columns 动态列选择，默认 t.*
- 使用 <sql id="EntityWhere"> 定义条件（动态 <if> 判断非空），字符串检查 `!= null and != ''`，数值/日期只检查 `!= null`
- 使用 <sql id="EntityJoins"> 定义关联（LEFT JOIN），默认为空
- 分页查询: IPage<Entity> pageSearch(Page<Entity> p, @Param("entity") Entity entity)
- list 查询: 使用 Oracle rownum 限制行数，默认 10000，通过 entity.rowNum 可配置
- Oracle 字符串拼接用 || 而非 CONCAT: `LIKE '%' || #{entity.field} || '%'`
- CDATA 用于特殊字符: `<![CDATA[<=]]>` for rownum <=
- 排序默认: ORDER BY t.id DESC，支持 entity.orderBy 动态排序
- XML 中不包含 org_id 列和 org_id 条件（多租户插件自动注入）
- 逻辑删除过滤: t.is_deleted = 0 或 t.is_delete = 0（如适用）
- 表别名统一使用 t
- XML 文件位置: src/main/resources/mapper/{submodule}/EntityMapper.xml

## 后端公共类引用

- ResponseWrapper: ofSuccess(data) / ofFail(msg) / defaultSuccess() / ofStatus(AIHttpStatus) — com.twsz.mom.core.common.ResponseWrapper
- AIHttpStatus: 枚举类，code 从已有最大值+1 递增（maxCode+1），禁止硬编码 — com.twsz.mom.core.common.AIHttpStatus
- BaseException: `new BaseException(AIHttpStatus.XXX.getCode(), AIHttpStatus.XXX.getMessage())` — com.twsz.mom.core.exception.BaseException
- PageForm<T>: size(页大小), current(页码,1-based), condition(查询条件对象) — com.twsz.mom.core.common.PageForm
- ApplicationContextUtil: 获取当前用户名称（getUserActName） — com.twsz.mom.core.utils.ApplicationContextUtil
- BaseExcelExportTemplate: 导出统一基类，匿名子类实现 getTotal() 与 getData(start, size)（start 从 1 开始），调用 exportBatch(response, converters...) — com.twsz.mom.ds.util.excel.BaseExcelExportTemplate
- BaseAnalysisEventListener: 导入统一监听器，new BaseAnalysisEventListener<T>(batchSize){ handle(list) }，配合 EasyExcel.read(...) 驱动 — com.twsz.mom.ds.util.excel.BaseAnalysisEventListener
- EasyExcel: 仅允许 EasyExcel.read(...) 驱动 BaseAnalysisEventListener，禁止 EasyExcel.write() — com.alibaba.excel.EasyExcel
- ExcelUtil: 【已废弃，禁止使用】 — com.twsz.mom.web.utils.ExcelUtil
- ViewModel.OPT_ADD = "1", OPT_MODIFY = "2", OPT_DELETE = "3", OPT_NOT_MODIFY_DATE = "0"
- IdUtil: cn.hutool.core.util.IdUtil（fastSimpleUUID 等）

## 前端 API 规范

- 使用 `import axios from '@/libs/request'`
- 导出使用 `import { exportExcel } from '@/api/file'` 或 `import { exportExcel } from '../../file'`
- API 文件放在 `src/api/{domain}/{entity}.js`
- 标准方法: save, deleteById, list, search, exportData
- save: POST, data 为实体对象
- deleteById: DELETE, data 为数组 `Array.isArray(id) ? id : [id]`
- search: POST, data 为 PageForm 对象
- list: POST, data 为查询条件对象
- exportData: 调用 exportExcel(url, data)
- URL 前缀根据模块不同有多种模式: `/mm/{entity}`, `/api/{entity}`, `/mm/api/{sub}/{entity}`
- 导入接口: `{ headers: { 'Content-Type': 'multipart/form-data' } }`

## 前端列表页规范（index.vue）

- 使用 indexPage mixin: `import { indexPage } from '_c/table-form/index-mixin'`
- 使用 tw-table 组件（非 search-table）
- option 配置: tableName(唯一标识), searchForm, fixedTableHeight, add/delete/edit/view/search/export
- option.add: `{ enable: true, method: this.add }`
- option.delete: `{ enable: true|fn(row), method: Api.deleteById, before: fn(rows) }`
- option.edit: `{ enable: true|fn(row), method: this.edit }`
- option.view: `{ enable: true, method: this.view }`
- option.search: `{ query: Api.search }`
- option.export: `{ enable: true, method: Api.exportData }`
- 表格列顺序: selection → 业务列 → 审计列(createdBy/createdDate/lastUpdatedBy/lastUpdatedDate) → operate
- 搜索条件: `<Form ref="conditionForm" class="search-condition-form" label-colon @keyup.enter.native="refresh" @submit.native.prevent>`
- 国际化: $t('key||中文')，如 $t('table.entityName.fieldName||字段中文名')
- 操作列: `<template slot-scope="{ row }" slot="operate">` 内含编辑/查看/删除按钮
- 权限控制: v-if="$auth('permission:key')"
- 列表/表单切换: showIndexPage 控制，triggerBack(false) 切换到表单，triggerBack(true) 回到列表

## 前端表单页规范（form.vue）

- 使用 BaseMixin: `import BaseMixin from '@/mixin'`，mixins: [BaseMixin]
- props: readonly(Boolean), form(Object)
- data: `{ default: {}, mform: {} }`，created 中 `this.mform = Object.assign({}, this.default, this.form)`
- 使用 master-sub 组件（主子表布局）:
  - `<template #master-header-toolbar>` — 提交按钮
  - `<template #master-form>` — 主表 Form
  - `<template #sub-table>` — 子表组件
- 表单提交: doSubmit() → this.$refs.mform.validate() → Api.save(this.mform) → $emit('on-success', data)
- 按钮状态: `<Button type="primary" :loading="loading" @click="doSubmit">`
- 只读模式: `<Form :disabled="readonly">`, 按钮 `v-if="!readonly"`
- 主子表提交: `this.asyncLoading(this.$refs.SubTableForm.save())` 委托子表 save()

## 前端子表规范（table-form.vue）

- 使用 tw-table: `<tw-table ref="searchTable" :option="option" :mainItem="mainItem" :columns="tableColumns" :tableRules="rules" :readonly="readonly">`
- option.save: 指向主表 Api.save（saveTable() 时使用）
- option.search.beforeQuery: `this.searchBefore` — 注入 mainId 过滤
- searchBefore(searchParam): `Object.assign(searchParam.condition, { mainId: this.mainItem.id }); return Boolean(this.mainItem.id)`
- 行内编辑 slot: `<template slot-scope="{ row, column, index, tableData, editIndex }" slot="input">`
- 编辑条件: `v-if="index === editIndex && !(column.disabled && Boolean(row.id))"`
- 必填列: `className: 'table-column-required'`
- 只读列: `readonly: true, disabled: 'true'`（审计字段等）
- tableRules: 定义每列校验规则 `{ fieldName: { required: true, type: 'string', message: '...' } }`
- save() 方法: 自定义校验 → `this.$refs.searchTable.saveTable()`
- opt 标记: 新增行 opt='1'，修改行 opt='2'，删除行 opt='3'

## 测试规范

- 测试类名: 原类名 + MockTest（如 PcCadDataServiceImpl → PcCadDataServiceImplMockTest）
- 框架: JUnit 5 + Mockito（@ExtendWith(MockitoExtension.class)，不是 @RunWith）
- @Mock 模拟依赖（Mapper、其他 Service）
- @InjectMocks 注入被测类
- setUp 中使用 ReflectionTestUtils.setField(entityService, "baseMapper", entityMapper) 注入 baseMapper
- 使用 @Nested 类按方法分组测试
- 使用 @DisplayName 描述测试场景（中文）
- 测试方法命名: testMethod_scenario() 或 should_预期行为_when_条件()
- 每个测试方法只测试一个行为
- Mock 外部依赖（Feign 调用、Redis、数据库），不依赖外部环境
- Arrange-Act-Assert 模式: when() → 调用方法 → assertEquals/verify
- 执行命令: mvn test -pl <module> -Dtest=ClassNameMockTest

## Java 通用编码规范

- 必须使用 Java 8 语法，禁止 Java 9+ 特性（var、模块化、HttpClient）
- 禁止 System.out.println，必须使用 @Slf4j + log.info/warn/error
- 禁止硬编码密码、密钥、连接串（使用 Jasypt 加密或 Nacos 配置中心）
- 禁止硬编码 IP 地址和端口
- 方法体不超过 80 行，超过必须拆分子方法
- 所有公共方法必须添加 Javadoc 注释
- 使用 4 空格缩进，禁止 Tab
- 禁止在 for 循环中发起数据库查询或远程调用

## Vue 通用编码规范

- 必须使用 Vue 2 Options API（data/methods/computed/watch）
- 禁止使用 Vue 3 Composition API（setup/ref/reactive）
- 组件 name 属性必须与文件名一致（PascalCase）
- props 必须定义 type 和 default
- 禁止直接修改 props，必须通过 $emit 通知父组件
- 统一使用 View Design 4.x 组件，禁止引入其他 UI 库
- 全局状态使用 Vuex，禁止在组件中直接修改 state
- 组件库: tw-table, master-sub, tw-card, RemoteSelect, TableSelect, RemoteCheckbox 等全局注册组件
