diff --git a/backend/components.d.ts b/backend/components.d.ts index 9017497..46b841b 100644 --- a/backend/components.d.ts +++ b/backend/components.d.ts @@ -25,6 +25,7 @@ declare module 'vue' { ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCol: typeof import('element-plus/es')['ElCol'] + ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElContainer: typeof import('element-plus/es')['ElContainer'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] diff --git a/backend/src/api/erp.js b/backend/src/api/erp.js deleted file mode 100644 index 84cde82..0000000 --- a/backend/src/api/erp.js +++ /dev/null @@ -1,155 +0,0 @@ -import request from "@/utils/request"; - -/************************************************* - ****************** 组织机构相关接口 ****************** - *************************************************/ - -/** - * 获取组织机构列表 - * @returns {Promise} - */ -export function getOrganizationList() { - return request({ - url: '/backend/erp/getOrganization', - method: 'get' - }); -} - -/** - * 获取组织机构详情 - * @param {number} id 组织机构ID - * @returns {Promise} - */ -export function getOrganizationDetail(id) { - return request({ - url: `/backend/erp/getOrganizationDetail/${id}`, - method: "get", - }); -} - -/** - * 创建组织机构数据 - * @param {Object} data 组织机构数据 - * @returns {Promise} - */ -export function createOrganization(data) { - return request({ - url: "/backend/erp/createOrganization", - method: "post", - data: data, - headers: { - "Content-Type": "multipart/form-data" - } - }); -} - -// 更新组织机构信息 -export function editOrganization(id, data) { - return request({ - url: `/backend/erp/editOrganization/${id}`, - method: 'post', - data: data - }); -} - -/** - * 删除组织机构数据 - * @param {number} id 组织机构ID - * @returns {Promise} - */ -export function deleteOrganization(id) { - return request({ - url: `/backend/erp/deleteOrganization/${id}`, - method: "delete", - }); -} - -/** - * 获取企业单位列表 - * @returns {Promise} - */ -export function getCompanys() { - return request({ - url: '/backend/erp/getCompanys', - method: 'get' - }); -} - -/** - * 获取部门列表 - * @param {number} parentId 隶属单位ID - * @returns {Promise} - */ -export function getDepartments(parentId) { - return request({ - url: '/backend/erp/getDepartments', - method: 'get', - params: parentId ? { parent_id: parentId } : {} - }); -} - -/************************************************* - ****************** 员工相关接口 ****************** - *************************************************/ - -/** - * 获取员工列表 - * @param {number} tenantId 租户ID - * @returns {Promise} - */ -export function getEmployeeList(tenantId) { - return request({ - url: '/backend/erp/getEmployee', - method: 'get', - params: { tid: tenantId } - }); -} - -/** - * 获取员工详情 - * @param {number} id 员工ID - * @returns {Promise} - */ -export function getEmployeeDetail(id) { - return request({ - url: `/backend/erp/getEmployeeDetail/${id}`, - method: "get", - }); -} - -/** - * 创建员工数据 - * @param {Object} data 员工数据 - * @returns {Promise} - */ -export function createEmployee(data) { - return request({ - url: "/backend/erp/createEmployee", - method: "post", - data: data, - headers: { - "Content-Type": "multipart/form-data" - } - }); -} - -// 更新员工信息 -export function editEmployee(id, data) { - return request({ - url: `/backend/erp/editEmployee/${id}`, - method: 'post', - data: data - }); -} - -/** - * 删除员工数据 - * @param {number} id 员工ID - * @returns {Promise} - */ -export function deleteEmployee(id) { - return request({ - url: `/backend/erp/deleteEmployee/${id}`, - method: "delete", - }); -} diff --git a/backend/src/api/organization.js b/backend/src/api/organization.js index b6b72ef..129f1b2 100644 --- a/backend/src/api/organization.js +++ b/backend/src/api/organization.js @@ -1,285 +1,215 @@ -import request from '@/utils/request' +import request from '@/utils/request'; -// 组织架构相关接口 +/** + * 组织架构接口(组织 / 员工 / 职位) + * + * 组织架构是租户端通用基础数据:进销存(erp)与办公自动化(oa)两个模块各自有独立界面, + * 但读写的是同一份数据,后端按登录租户自动隔离。两端接口路径只有模块前缀不同, + * 因此这里用工厂函数按模块生成一套接口。 + */ -// 获取组织架构列表 -export function getOrganizationList(params) { - return request({ - url: '/backend/erp/getOrganization', - method: 'get', - params - }) +const MODULES = ['erp', 'oa']; + +function normalizeModule(module) { + const value = String(module || '').toLowerCase(); + return MODULES.includes(value) ? value : 'erp'; } -// 获取组织架构详情 -export function getOrganizationDetail(id) { - return request({ - url: `/backend/erp/getOrganizationDetail/${id}`, - method: 'get' - }) +export function createOrganizationApi(module) { + const base = `/backend/${normalizeModule(module)}`; + + return { + module: normalizeModule(module), + + /* ------------------------------- 组织机构 ------------------------------- */ + + getOrganizationList(params) { + return request({ url: `${base}/getOrganization`, method: 'get', params }); + }, + + getOrganizationTree(params) { + return request({ url: `${base}/getOrganizationTree`, method: 'get', params }); + }, + + getOrganizationDetail(id) { + return request({ url: `${base}/getOrganizationDetail/${id}`, method: 'get' }); + }, + + createOrganization(data) { + return request({ url: `${base}/createOrganization`, method: 'post', data }); + }, + + updateOrganization(id, data) { + return request({ url: `${base}/editOrganization/${id}`, method: 'post', data }); + }, + + deleteOrganization(id) { + return request({ url: `${base}/deleteOrganization/${id}`, method: 'delete' }); + }, + + getCompanyList(params) { + return request({ url: `${base}/getCompanys`, method: 'get', params }); + }, + + getDepartmentList(parentId) { + return request({ + url: `${base}/getDepartments`, + method: 'get', + params: parentId ? { parent_id: parentId } : {}, + }); + }, + + searchOrganizations(keyword) { + return request({ url: `${base}/searchOrganizations`, method: 'get', params: { keyword } }); + }, + + getOrganizationHierarchy(orgId) { + return request({ url: `${base}/getOrganizationHierarchy/${orgId}`, method: 'get' }); + }, + + getOrganizationStats() { + return request({ url: `${base}/getOrganizationStats`, method: 'get' }); + }, + + /** 移动组织节点到新的上级(拖拽调整层级) */ + moveOrganization(orgId, parentId, sort) { + const data = { org_id: orgId, parent_id: parentId || 0 }; + if (sort !== undefined && sort !== null) { + data.sort = sort; + } + return request({ url: `${base}/moveOrganization`, method: 'post', data }); + }, + + /** 批量操作组织,action 取 enable / disable / delete */ + batchOrganizeOrganizations(ids, action) { + return request({ + url: `${base}/batchOrganizeOrganizations`, + method: 'post', + data: { ids, action }, + }); + }, + + checkOrgCodeUnique(orgCode, excludeId) { + return request({ + url: `${base}/checkOrgCodeUnique`, + method: 'get', + params: { org_code: orgCode, exclude_id: excludeId || '' }, + }); + }, + + /* --------------------------------- 员工 --------------------------------- */ + + getEmployeeList(params) { + return request({ url: `${base}/getEmployee`, method: 'get', params }); + }, + + getEmployeeDetail(id) { + return request({ url: `${base}/getEmployeeDetail/${id}`, method: 'get' }); + }, + + createEmployee(data) { + return request({ url: `${base}/createEmployee`, method: 'post', data }); + }, + + updateEmployee(id, data) { + return request({ url: `${base}/editEmployee/${id}`, method: 'post', data }); + }, + + deleteEmployee(id) { + return request({ url: `${base}/deleteEmployee/${id}`, method: 'delete' }); + }, + + getOrganizationEmployees(orgId) { + return request({ url: `${base}/getOrganizationEmployees/${orgId}`, method: 'get' }); + }, + + /** 员工调岗:支持单个或批量 */ + moveEmployeeToOrg(employeeIds, orgId, position) { + const ids = Array.isArray(employeeIds) ? employeeIds : [employeeIds]; + const data = { employee_ids: ids, org_id: orgId }; + if (position !== undefined && position !== null) { + data.position = position; + } + return request({ url: `${base}/moveEmployeeToOrg`, method: 'post', data }); + }, + + checkEmployeeAccountUnique(account, excludeId) { + return request({ + url: `${base}/checkEmployeeAccountUnique`, + method: 'get', + params: { account, exclude_id: excludeId || '' }, + }); + }, + + /* --------------------------------- 职位 --------------------------------- */ + + getPositionList(params) { + return request({ url: `${base}/getPosition`, method: 'get', params }); + }, + + getPositionDetail(id) { + return request({ url: `${base}/getPositionDetail/${id}`, method: 'get' }); + }, + + createPosition(data) { + return request({ url: `${base}/createPosition`, method: 'post', data }); + }, + + updatePosition(id, data) { + return request({ url: `${base}/editPosition/${id}`, method: 'post', data }); + }, + + deletePosition(id) { + return request({ url: `${base}/deletePosition/${id}`, method: 'delete' }); + }, + + checkPositionCodeUnique(positionCode, excludeId) { + return request({ + url: `${base}/checkPositionCodeUnique`, + method: 'get', + params: { position_code: positionCode, exclude_id: excludeId || '' }, + }); + }, + + /* ----------------------------- 设置与导入导出 ----------------------------- */ + + getOrgSettings() { + return request({ url: `${base}/getOrgSettings`, method: 'get' }); + }, + + saveOrgSettings(data) { + return request({ url: `${base}/saveOrgSettings`, method: 'post', data }); + }, + + exportOrganization() { + return request({ url: `${base}/exportOrganization`, method: 'get', responseType: 'blob' }); + }, + + downloadImportTemplate() { + return request({ + url: `${base}/organizationImportTemplate`, + method: 'get', + responseType: 'blob', + }); + }, + + importOrganization(file) { + const data = new FormData(); + data.append('file', file); + return request({ + url: `${base}/importOrganization`, + method: 'post', + data, + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }, + }; } -// 创建组织架构 -export function createOrganization(data) { - return request({ - url: '/backend/erp/createOrganization', - method: 'post', - data - }) -} +/** 进销存模块的组织架构接口 */ +export const erpOrganizationApi = createOrganizationApi('erp'); -// 更新组织架构 -export function updateOrganization(id, data) { - return request({ - url: `/backend/erp/editOrganization/${id}`, - method: 'post', - data - }) -} +/** 办公自动化模块的组织架构接口 */ +export const oaOrganizationApi = createOrganizationApi('oa'); -// 删除组织架构 -export function deleteOrganization(id) { - return request({ - url: `/backend/erp/deleteOrganization/${id}`, - method: 'delete' - }) -} - -// 获取企业单位列表 -export function getCompanyList(params) { - return request({ - url: '/backend/erp/getCompanys', - method: 'get', - params - }) -} - -// 获取部门列表 -export function getDepartmentList(params) { - return request({ - url: '/backend/erp/getDepartments', - method: 'get', - params - }) -} - -// 员工相关接口 - -// 获取员工列表 -export function getEmployeeList(params) { - return request({ - url: '/backend/erp/getEmployee', - method: 'get', - params - }) -} - -// 获取员工详情 -export function getEmployeeDetail(id) { - return request({ - url: `/backend/erp/getEmployeeDetail/${id}`, - method: 'get' - }) -} - -// 创建员工 -export function createEmployee(data) { - return request({ - url: '/backend/erp/createEmployee', - method: 'post', - data - }) -} - -// 更新员工 -export function updateEmployee(id, data) { - return request({ - url: `/backend/erp/editEmployee/${id}`, - method: 'post', - data - }) -} - -// 删除员工 -export function deleteEmployee(id) { - return request({ - url: `/backend/erp/deleteEmployee/${id}`, - method: 'delete' - }) -} - -// 职位相关接口 - -// 获取职位列表 -export function getPositionList(params) { - return request({ - url: '/backend/erp/getPosition', - method: 'get', - params - }) -} - -// 获取职位详情 -export function getPositionDetail(id) { - return request({ - url: `/backend/erp/getPositionDetail/${id}`, - method: 'get' - }) -} - -// 创建职位 -export function createPosition(data) { - return request({ - url: '/backend/erp/createPosition', - method: 'post', - data - }) -} - -// 更新职位 -export function updatePosition(id, data) { - return request({ - url: `/backend/erp/editPosition/${id}`, - method: 'post', - data - }) -} - -// 删除职位 -export function deletePosition(id) { - return request({ - url: `/backend/erp/deletePosition/${id}`, - method: 'delete' - }) -} - -// 组织设置相关接口 - -// 获取组织设置 -export function getOrgSettings() { - return request({ - url: '/backend/erp/getOrgSettings', - method: 'get' - }) -} - -// 保存组织设置 -export function saveOrgSettings(data) { - return request({ - url: '/backend/erp/saveOrgSettings', - method: 'post', - data - }) -} - -// 组织架构导出 -export function exportOrganization(params) { - return request({ - url: '/backend/erp/exportOrganization', - method: 'get', - params, - responseType: 'blob' - }) -} - -// 组织架构导入 -export function importOrganization(data) { - return request({ - url: '/backend/erp/importOrganization', - method: 'post', - data, - headers: { - 'Content-Type': 'multipart/form-data' - } - }) -} - -// 批量操作组织 -export function batchOrganizeOrganizations(ids, action) { - return request({ - url: '/backend/erp/batchOrganizeOrganizations', - method: 'post', - data: { - ids, - action - } - }) -} - -// 获取组织架构统计信息 -export function getOrganizationStats() { - return request({ - url: '/backend/erp/getOrganizationStats', - method: 'get' - }) -} - -// 获取组织架构树形结构 -export function getOrganizationTree(params) { - return request({ - url: '/backend/erp/getOrganizationTree', - method: 'get', - params - }) -} - -// 搜索组织架构 -export function searchOrganizations(keyword) { - return request({ - url: '/backend/erp/searchOrganizations', - method: 'get', - params: { keyword } - }) -} - -// 获取组织架构下的所有员工 -export function getOrganizationEmployees(orgId) { - return request({ - url: `/backend/erp/getOrganizationEmployees/${orgId}`, - method: 'get' - }) -} - -// 移动员工到其他组织 -export function moveEmployeeToOrg(employeeId, orgId) { - return request({ - url: '/backend/erp/moveEmployeeToOrg', - method: 'post', - data: { - employee_id: employeeId, - org_id: orgId - } - }) -} - -// 获取组织架构层级关系 -export function getOrganizationHierarchy(orgId) { - return request({ - url: `/backend/erp/getOrganizationHierarchy/${orgId}`, - method: 'get' - }) -} - -// 验证组织编码唯一性 -export function checkOrgCodeUnique(orgCode, excludeId) { - return request({ - url: '/backend/erp/checkOrgCodeUnique', - method: 'get', - params: { - org_code: orgCode, - exclude_id: excludeId || '' - } - }) -} - -// 验证员工账号唯一性 -export function checkEmployeeAccountUnique(account, excludeId) { - return request({ - url: '/backend/erp/checkEmployeeAccountUnique', - method: 'get', - params: { - account, - exclude_id: excludeId || '' - } - }) -} +export default erpOrganizationApi; diff --git a/backend/src/env.d.ts b/backend/src/env.d.ts index 1e3c0b5..c09e1c2 100644 --- a/backend/src/env.d.ts +++ b/backend/src/env.d.ts @@ -12,19 +12,12 @@ declare module '@/*' { export default component; } -declare module '@/api/erp' { - export function getOrganizationList(): Promise; - export function getOrganizationDetail(id: number | string): Promise; - export function createOrganization(data: any): Promise; - export function editOrganization(id: number | string, data: any): Promise; - export function deleteOrganization(id: number | string): Promise; - export function getCompanys(): Promise; - export function getDepartments(parentId?: number | string): Promise; - export function getEmployeeList(tenantId?: number | string): Promise; - export function getEmployeeDetail(id: number | string): Promise; - export function createEmployee(data: any): Promise; - export function editEmployee(id: number | string, data: any): Promise; - export function deleteEmployee(id: number | string): Promise; +declare module '@/api/organization' { + export function createOrganizationApi(module: string): any; + export const erpOrganizationApi: any; + export const oaOrganizationApi: any; + const api: any; + export default api; } declare module '@/stores/auth' { diff --git a/backend/src/router/index.js b/backend/src/router/index.js index 794ab77..c563c6f 100644 --- a/backend/src/router/index.js +++ b/backend/src/router/index.js @@ -69,13 +69,44 @@ const staticMainChildren = [ component: () => import("@/views/user/userProfile.vue"), meta: { requiresAuth: true, title: "用户中心" } }, - // OA 组织架构 + // 组织架构(组织 / 员工 / 职位) + // 进销存(erp)与办公自动化(oa)各有独立页面,但读写同一份组织数据。 + { + path: "/apps/erp/organization", + name: "ErpOrganization", + component: () => import("@/views/apps/erp/organization/index.vue"), + meta: { requiresAuth: true, title: "组织架构", modulePath: "/apps/erp" } + }, + { + path: "/apps/erp/employee", + name: "ErpEmployee", + component: () => import("@/views/apps/erp/employee/index.vue"), + meta: { requiresAuth: true, title: "员工管理", modulePath: "/apps/erp" } + }, + { + path: "/apps/erp/position", + name: "ErpPosition", + component: () => import("@/views/apps/erp/position/index.vue"), + meta: { requiresAuth: true, title: "职位管理", modulePath: "/apps/erp" } + }, { path: "/apps/oa/organization", name: "Organization", component: () => import("@/views/apps/oa/organization/index.vue"), meta: { requiresAuth: true, title: "组织架构", modulePath: "/apps/oa" } }, + { + path: "/apps/oa/employee", + name: "OaEmployee", + component: () => import("@/views/apps/oa/employee/index.vue"), + meta: { requiresAuth: true, title: "人员管理", modulePath: "/apps/oa" } + }, + { + path: "/apps/oa/position", + name: "OaPosition", + component: () => import("@/views/apps/oa/position/index.vue"), + meta: { requiresAuth: true, title: "职位管理", modulePath: "/apps/oa" } + }, { path: "/tools/passwordStore", name: "BackendPasswordStore", diff --git a/backend/src/views/apps/erp/employee/components/changepass.vue b/backend/src/views/apps/erp/employee/components/changepass.vue deleted file mode 100644 index 5baba7a..0000000 --- a/backend/src/views/apps/erp/employee/components/changepass.vue +++ /dev/null @@ -1,122 +0,0 @@ - - - - - diff --git a/backend/src/views/apps/erp/employee/components/edit.vue b/backend/src/views/apps/erp/employee/components/edit.vue deleted file mode 100644 index f7f8452..0000000 --- a/backend/src/views/apps/erp/employee/components/edit.vue +++ /dev/null @@ -1,583 +0,0 @@ - - - - - diff --git a/backend/src/views/apps/erp/employee/components/view.vue b/backend/src/views/apps/erp/employee/components/view.vue deleted file mode 100644 index 858f3bb..0000000 --- a/backend/src/views/apps/erp/employee/components/view.vue +++ /dev/null @@ -1,97 +0,0 @@ - - - - - diff --git a/backend/src/views/apps/erp/employee/index.vue b/backend/src/views/apps/erp/employee/index.vue index 6329e21..6004c08 100644 --- a/backend/src/views/apps/erp/employee/index.vue +++ b/backend/src/views/apps/erp/employee/index.vue @@ -1,201 +1,8 @@ - - - diff --git a/backend/src/views/apps/erp/organization/components/edit.vue b/backend/src/views/apps/erp/organization/components/edit.vue deleted file mode 100644 index 69dc988..0000000 --- a/backend/src/views/apps/erp/organization/components/edit.vue +++ /dev/null @@ -1,252 +0,0 @@ - - diff --git a/backend/src/views/apps/erp/organization/index.vue b/backend/src/views/apps/erp/organization/index.vue index 0a9ecd6..24c2fa3 100644 --- a/backend/src/views/apps/erp/organization/index.vue +++ b/backend/src/views/apps/erp/organization/index.vue @@ -1,358 +1,8 @@ - - - diff --git a/backend/src/views/apps/erp/position/index.vue b/backend/src/views/apps/erp/position/index.vue new file mode 100644 index 0000000..7575216 --- /dev/null +++ b/backend/src/views/apps/erp/position/index.vue @@ -0,0 +1,8 @@ + + + diff --git a/backend/src/views/apps/oa/employee/index.vue b/backend/src/views/apps/oa/employee/index.vue new file mode 100644 index 0000000..48ccdb4 --- /dev/null +++ b/backend/src/views/apps/oa/employee/index.vue @@ -0,0 +1,8 @@ + + + diff --git a/backend/src/views/apps/oa/organization/components/employeeEditDialog.vue b/backend/src/views/apps/oa/organization/components/employeeEditDialog.vue deleted file mode 100644 index 17e8219..0000000 --- a/backend/src/views/apps/oa/organization/components/employeeEditDialog.vue +++ /dev/null @@ -1,383 +0,0 @@ - - - - - diff --git a/backend/src/views/apps/oa/organization/components/orgEditDialog.vue b/backend/src/views/apps/oa/organization/components/orgEditDialog.vue deleted file mode 100644 index 5bb7b49..0000000 --- a/backend/src/views/apps/oa/organization/components/orgEditDialog.vue +++ /dev/null @@ -1,301 +0,0 @@ - - - - - diff --git a/backend/src/views/apps/oa/organization/components/orgSettingsDialog.vue b/backend/src/views/apps/oa/organization/components/orgSettingsDialog.vue deleted file mode 100644 index 9984529..0000000 --- a/backend/src/views/apps/oa/organization/components/orgSettingsDialog.vue +++ /dev/null @@ -1,259 +0,0 @@ - - - - - diff --git a/backend/src/views/apps/oa/organization/index.vue b/backend/src/views/apps/oa/organization/index.vue index 4eceed1..248c98a 100644 --- a/backend/src/views/apps/oa/organization/index.vue +++ b/backend/src/views/apps/oa/organization/index.vue @@ -1,696 +1,8 @@ - - diff --git a/backend/src/views/apps/oa/position/index.vue b/backend/src/views/apps/oa/position/index.vue new file mode 100644 index 0000000..aa11eb7 --- /dev/null +++ b/backend/src/views/apps/oa/position/index.vue @@ -0,0 +1,8 @@ + + + diff --git a/backend/src/views/apps/organization/components/EmployeeEditDialog.vue b/backend/src/views/apps/organization/components/EmployeeEditDialog.vue new file mode 100644 index 0000000..238cc4d --- /dev/null +++ b/backend/src/views/apps/organization/components/EmployeeEditDialog.vue @@ -0,0 +1,344 @@ + + + + + diff --git a/backend/src/views/apps/organization/components/EmployeePage.vue b/backend/src/views/apps/organization/components/EmployeePage.vue new file mode 100644 index 0000000..4660b42 --- /dev/null +++ b/backend/src/views/apps/organization/components/EmployeePage.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/backend/src/views/apps/organization/components/EmployeeViewDialog.vue b/backend/src/views/apps/organization/components/EmployeeViewDialog.vue new file mode 100644 index 0000000..6372759 --- /dev/null +++ b/backend/src/views/apps/organization/components/EmployeeViewDialog.vue @@ -0,0 +1,77 @@ + + + diff --git a/backend/src/views/apps/organization/components/ImportExportDialog.vue b/backend/src/views/apps/organization/components/ImportExportDialog.vue new file mode 100644 index 0000000..cf3c08a --- /dev/null +++ b/backend/src/views/apps/organization/components/ImportExportDialog.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/backend/src/views/apps/organization/components/MoveEmployeeDialog.vue b/backend/src/views/apps/organization/components/MoveEmployeeDialog.vue new file mode 100644 index 0000000..b91ca7e --- /dev/null +++ b/backend/src/views/apps/organization/components/MoveEmployeeDialog.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/backend/src/views/apps/organization/components/OrgEditDialog.vue b/backend/src/views/apps/organization/components/OrgEditDialog.vue new file mode 100644 index 0000000..000fcf3 --- /dev/null +++ b/backend/src/views/apps/organization/components/OrgEditDialog.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/backend/src/views/apps/organization/components/OrgSettingsDialog.vue b/backend/src/views/apps/organization/components/OrgSettingsDialog.vue new file mode 100644 index 0000000..f801634 --- /dev/null +++ b/backend/src/views/apps/organization/components/OrgSettingsDialog.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/backend/src/views/apps/organization/components/OrganizationPage.vue b/backend/src/views/apps/organization/components/OrganizationPage.vue new file mode 100644 index 0000000..0d1f79f --- /dev/null +++ b/backend/src/views/apps/organization/components/OrganizationPage.vue @@ -0,0 +1,617 @@ + + + + + diff --git a/backend/src/views/apps/organization/components/PositionEditDialog.vue b/backend/src/views/apps/organization/components/PositionEditDialog.vue new file mode 100644 index 0000000..c37d326 --- /dev/null +++ b/backend/src/views/apps/organization/components/PositionEditDialog.vue @@ -0,0 +1,178 @@ + + + diff --git a/backend/src/views/apps/organization/components/PositionPage.vue b/backend/src/views/apps/organization/components/PositionPage.vue new file mode 100644 index 0000000..4028d0d --- /dev/null +++ b/backend/src/views/apps/organization/components/PositionPage.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/backend/src/views/apps/organization/composables.js b/backend/src/views/apps/organization/composables.js new file mode 100644 index 0000000..d8b161a --- /dev/null +++ b/backend/src/views/apps/organization/composables.js @@ -0,0 +1,117 @@ +import { erpOrganizationApi, oaOrganizationApi } from '@/api/organization'; + +/** + * 组织架构模块前端共用逻辑。 + * + * 进销存(erp)与办公自动化(oa)两端界面共用同一批组件, + * 只通过 module 属性切换调用的接口前缀。 + */ + +const API_BY_MODULE = { + erp: erpOrganizationApi, + oa: oaOrganizationApi, +}; + +/** 按模块取对应的接口集合,模块名非法时回退到 erp */ +export function useOrganizationApi(module) { + return API_BY_MODULE[String(module || '').toLowerCase()] || erpOrganizationApi; +} + +/** 两端在文案上的差异:进销存偏组织与职位,办公自动化偏人员与审批链 */ +export const MODULE_LABELS = { + erp: { name: '进销存', employeeLabel: '员工', orgLabel: '组织' }, + oa: { name: '办公自动化', employeeLabel: '人员', orgLabel: '组织' }, +}; + +export function moduleLabels(module) { + return MODULE_LABELS[String(module || '').toLowerCase()] || MODULE_LABELS.erp; +} + +/** + * 后端统一返回 { code, msg, data },axios 拦截器已经剥掉一层 response。 + * 这里把业务错误转成异常,让调用方用 try/catch 统一处理。 + */ +export function unwrap(res) { + if (res && typeof res === 'object' && 'code' in res) { + if (Number(res.code) !== 200) { + throw new Error(res.msg || '请求失败'); + } + return res.data; + } + return res; +} + +/** 把扁平的组织列表组装成树;上级缺失的节点作为根节点,避免数据丢失 */ +export function buildOrgTree(list) { + const rows = Array.isArray(list) ? list : []; + const nodeMap = new Map(); + rows.forEach((item) => { + nodeMap.set(Number(item.id), { ...item, children: [] }); + }); + + const tree = []; + rows.forEach((item) => { + const node = nodeMap.get(Number(item.id)); + const parentId = Number(item.parent_id || 0); + const parent = parentId ? nodeMap.get(parentId) : null; + if (parent) { + parent.children.push(node); + } else { + tree.push(node); + } + }); + return tree; +} + +/** 收集节点及其所有子节点的 ID,用于判断"不能移动到自己的下级" */ +export function collectSubtreeIds(node) { + const ids = []; + const walk = (item) => { + if (!item) return; + ids.push(Number(item.id)); + (item.children || []).forEach(walk); + }; + walk(node); + return ids; +} + +/** 触发浏览器下载 */ +export function downloadBlob(blob, filename) { + const url = window.URL.createObjectURL( + blob instanceof Blob ? blob : new Blob([blob], { type: 'text/csv;charset=utf-8' }) + ); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); +} + +export function formatDateTime(value) { + if (!value) return '-'; + return String(value).replace('T', ' ').slice(0, 19); +} + +export const GENDER_TEXT = { 0: '未知', 1: '男', 2: '女' }; + +export function genderText(row) { + const value = Number(row?.gender ?? row?.sex ?? 0); + return GENDER_TEXT[value] || '未知'; +} + +/** 员工账号状态:1 启用 / 0 禁用 / 2 离职 */ +export function employeeStatusText(row) { + const value = Number(row?.account_status ?? row?.status ?? 0); + if (value === 1) return '正常'; + if (value === 2) return '离职'; + return '禁用'; +} + +export function employeeStatusTagType(row) { + const value = Number(row?.account_status ?? row?.status ?? 0); + if (value === 1) return 'success'; + if (value === 2) return 'warning'; + return 'danger'; +} diff --git a/go/controllers/backend_erp.go b/go/controllers/backend_erp.go deleted file mode 100644 index bc582c6..0000000 --- a/go/controllers/backend_erp.go +++ /dev/null @@ -1,1428 +0,0 @@ -package controllers - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "strconv" - "strings" - "time" - - "server/models" - - "github.com/beego/beego/v2/client/orm" - beego "github.com/beego/beego/v2/server/web" -) - -// BackendErpController 兼容 backend 前端 /admin/erp/* 组织机构、员工、职位接口。 -type BackendErpController struct { - beego.Controller -} - -type erpOrganizationDTO struct { - ID uint64 `json:"id"` - Tid uint64 `json:"tid"` - TenantID uint64 `json:"tenant_id"` - OrgName string `json:"org_name"` - OrgCode string `json:"org_code"` - ParentID uint64 `json:"parent_id"` - ParentName string `json:"parent_name"` - LeaderID uint64 `json:"leader_id"` - LeaderName string `json:"leader_name"` - IsCompany int `json:"is_company"` - Sort uint `json:"sort"` - Status int8 `json:"status"` - Remark string `json:"remark"` -} - -type erpEmployeeDTO struct { - ID uint `json:"id"` - Tid int `json:"tid"` - TenantID int `json:"tenant_id"` - Account string `json:"account"` - Name string `json:"name"` - Gender int8 `json:"gender"` - Sex int8 `json:"sex"` - Birthday string `json:"birthday"` - AffiliateUnit string `json:"affiliate_unit"` - AffiliateUnitName string `json:"affiliate_unit_name"` - Department string `json:"department"` - DepartmentName string `json:"department_name"` - Position string `json:"position"` - Education string `json:"education"` - Nation string `json:"nation"` - Phone string `json:"phone"` - Wechat string `json:"wechat"` - Email string `json:"email"` - HomeAddress string `json:"home_address"` - AccountStatus int8 `json:"account_status"` - Status int8 `json:"status"` -} - -type erpPositionDTO struct { - ID uint64 `json:"id"` - TenantID uint64 `json:"tenant_id"` - Tid uint64 `json:"tid"` - DepartmentID uint64 `json:"department_id"` - PositionCode string `json:"position_code"` - PositionName string `json:"position_name"` - PositionType int8 `json:"position_type"` - Status int8 `json:"status"` - Sort uint `json:"sort"` -} - -// GetOrganization 获取组织机构列表。 -// GET /admin/erp/getOrganization -func (c *BackendErpController) GetOrganization() { - tid, _ := c.GetInt("tid") - - qs := models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("delete_time__isnull", true). - Exclude("status", 0) - if tid > 0 { - qs = qs.Filter("tid", tid) - } - - var rows []models.BackendErpOrganization - _, err := qs.OrderBy("sort", "id").All(&rows) - if err != nil { - c.jsonError(500, "查询组织机构失败: "+err.Error()) - return - } - - list := make([]erpOrganizationDTO, 0, len(rows)) - for _, row := range rows { - list = append(list, c.organizationDTO(row)) - } - - c.jsonOK(list) -} - -// GetOrganizationDetail 获取组织机构详情。 -// GET /admin/erp/getOrganizationDetail/:id -func (c *BackendErpController) GetOrganizationDetail() { - id, ok := c.pathUint64(":id") - if !ok { - c.jsonError(400, "无效ID") - return - } - - var row models.BackendErpOrganization - err := models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("id", id). - Filter("delete_time__isnull", true). - Exclude("status", 0). - One(&row) - if err != nil { - c.jsonError(404, "组织机构不存在") - return - } - - c.jsonOK(c.organizationDTO(row)) -} - -// CreateOrganization 创建组织机构。 -// POST /admin/erp/createOrganization -func (c *BackendErpController) CreateOrganization() { - body := c.parseJSONBody() - - orgName, _ := c.getStringValue(body, "org_name", "name") - orgName = strings.TrimSpace(orgName) - if orgName == "" { - c.jsonError(400, "组织名称不能为空") - return - } - - orgCode, _ := c.getStringValue(body, "org_code", "code") - orgCode = strings.TrimSpace(orgCode) - if orgCode == "" { - orgCode = "ORG" + c.nowCompactString() - } - - tid, _ := c.getUint64Value(body, "tid", "tenant_id") - parentID, _ := c.getUint64Value(body, "parent_id") - leaderID, hasLeader := c.getUint64Value(body, "leader_id") - sortVal, _ := c.getUintValue(body, "sort") - isCompany, hasCompany := c.getIntValue(body, "is_company") - status, hasStatus := c.getIntValue(body, "status") - remark, _ := c.getStringValue(body, "remark") - - row := models.BackendErpOrganization{ - Tid: tid, - OrgName: orgName, - OrgCode: orgCode, - ParentID: parentID, - Sort: sortVal, - IsCompany: boolInt(parentID == 0), - Status: 1, - Remark: strPtrIfNotEmpty(remark), - } - if hasLeader && leaderID > 0 { - row.LeaderID = &leaderID - } - if hasCompany { - row.IsCompany = isCompany - } - if hasStatus { - row.Status = int8(status) - } - - id, err := models.Orm.Insert(&row) - if err != nil { - c.jsonError(500, "创建组织机构失败: "+err.Error()) - return - } - - c.jsonOK(map[string]interface{}{"id": id}) -} - -// EditOrganization 更新组织机构。 -// POST /admin/erp/editOrganization/:id -func (c *BackendErpController) EditOrganization() { - id, ok := c.pathUint64(":id") - if !ok { - c.jsonError(400, "无效ID") - return - } - - body := c.parseJSONBody() - update := orm.Params{} - - if v, has := c.getStringValue(body, "org_name", "name"); has { - v = strings.TrimSpace(v) - if v == "" { - c.jsonError(400, "组织名称不能为空") - return - } - update["org_name"] = v - } - if v, has := c.getStringValue(body, "org_code", "code"); has { - update["org_code"] = strings.TrimSpace(v) - } - if v, has := c.getUint64Value(body, "parent_id"); has { - update["parent_id"] = v - } - if v, has := c.getUint64Value(body, "tid", "tenant_id"); has { - update["tid"] = v - } - if v, has := c.getUint64Value(body, "leader_id"); has { - update["leader_id"] = nullableUint64(v) - } - if v, has := c.getUintValue(body, "sort"); has { - update["sort"] = v - } - if v, has := c.getIntValue(body, "is_company"); has { - update["is_company"] = v - } - if v, has := c.getIntValue(body, "status"); has { - update["status"] = int8(v) - } - if v, has := c.getStringValue(body, "remark"); has { - update["remark"] = nullableString(v) - } - - if len(update) == 0 { - c.jsonError(400, "无更新字段") - return - } - - num, err := models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("id", id). - Filter("delete_time__isnull", true). - Update(update) - if err != nil { - c.jsonError(500, "更新组织机构失败: "+err.Error()) - return - } - if num == 0 { - c.jsonError(404, "组织机构不存在") - return - } - - c.jsonOK(nil) -} - -// DeleteOrganization 删除组织机构。 -// DELETE /admin/erp/deleteOrganization/:id -func (c *BackendErpController) DeleteOrganization() { - id, ok := c.pathUint64(":id") - if !ok { - c.jsonError(400, "无效ID") - return - } - - childCount, err := models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("parent_id", id). - Filter("delete_time__isnull", true). - Exclude("status", 0). - Count() - if err != nil { - c.jsonError(500, "检查子组织失败: "+err.Error()) - return - } - if childCount > 0 { - c.jsonError(400, "请先删除下级组织") - return - } - - now := c.nowString() - num, err := models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("id", id). - Filter("delete_time__isnull", true). - Update(orm.Params{"delete_time": now, "status": int8(0)}) - if err != nil { - c.jsonError(500, "删除组织机构失败: "+err.Error()) - return - } - if num == 0 { - c.jsonError(404, "组织机构不存在") - return - } - - c.jsonOK(nil) -} - -// GetCompanys 获取企业单位列表。 -// GET /admin/erp/getCompanys -func (c *BackendErpController) GetCompanys() { - tid, _ := c.GetInt("tid") - - qs := models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("delete_time__isnull", true). - Exclude("status", 0). - Filter("is_company", 1) - if tid > 0 { - qs = qs.Filter("tid", tid) - } - - var rows []models.BackendErpOrganization - _, err := qs.OrderBy("sort", "id").All(&rows) - if err != nil { - c.jsonError(500, "查询企业单位失败: "+err.Error()) - return - } - - list := make([]erpOrganizationDTO, 0, len(rows)) - for _, row := range rows { - list = append(list, c.organizationDTO(row)) - } - - c.jsonOK(list) -} - -// GetDepartments 获取部门列表。 -// GET /admin/erp/getDepartments?parent_id=1 -func (c *BackendErpController) GetDepartments() { - parentID, _ := c.GetInt("parent_id") - tid, _ := c.GetInt("tid") - - qs := models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("delete_time__isnull", true). - Exclude("status", 0) - if parentID > 0 { - qs = qs.Filter("parent_id", parentID) - } else { - qs = qs.Filter("is_company", 0) - } - if tid > 0 { - qs = qs.Filter("tid", tid) - } - - var rows []models.BackendErpOrganization - _, err := qs.OrderBy("sort", "id").All(&rows) - if err != nil { - c.jsonError(500, "查询部门失败: "+err.Error()) - return - } - - list := make([]erpOrganizationDTO, 0, len(rows)) - for _, row := range rows { - list = append(list, c.organizationDTO(row)) - } - - c.jsonOK(list) -} - -// GetEmployee 获取员工列表。 -// GET /admin/erp/getEmployee?tid=1 -func (c *BackendErpController) GetEmployee() { - tid, _ := c.GetInt("tid") - - qs := models.Orm.QueryTable(new(models.BackendErpEmployee)).Filter("delete_time__isnull", true) - if tid > 0 { - qs = qs.Filter("tid", tid) - } - - var rows []models.BackendErpEmployee - _, err := qs.OrderBy("-id").All(&rows) - if err != nil { - c.jsonError(500, "查询员工失败: "+err.Error()) - return - } - - list := make([]erpEmployeeDTO, 0, len(rows)) - for _, row := range rows { - list = append(list, c.employeeDTO(row)) - } - - c.jsonOK(list) -} - -// GetEmployeeDetail 获取员工详情。 -// GET /admin/erp/getEmployeeDetail/:id -func (c *BackendErpController) GetEmployeeDetail() { - id, ok := c.pathUint(":id") - if !ok { - c.jsonError(400, "无效ID") - return - } - - var row models.BackendErpEmployee - err := models.Orm.QueryTable(new(models.BackendErpEmployee)). - Filter("id", id). - Filter("delete_time__isnull", true). - One(&row) - if err != nil { - c.jsonError(404, "员工不存在") - return - } - - c.jsonOK(c.employeeDTO(row)) -} - -// CreateEmployee 创建员工。 -// POST /admin/erp/createEmployee -func (c *BackendErpController) CreateEmployee() { - body := c.parseJSONBody() - - name, _ := c.getStringValue(body, "name") - name = strings.TrimSpace(name) - if name == "" { - c.jsonError(400, "姓名不能为空") - return - } - - account, _ := c.getStringValue(body, "account") - account = strings.TrimSpace(account) - if account == "" { - account = "EMP" + c.nowCompactString() - } - - tid, _ := c.getIntValue(body, "tid", "tenant_id") - gender, hasGender := c.getIntValue(body, "gender", "sex") - status, hasStatus := c.getIntValue(body, "account_status", "status") - password, _ := c.getStringValue(body, "password") - birthday, _ := c.getStringValue(body, "birthday") - affiliateUnit, _ := c.getStringValue(body, "affiliate_unit") - department, _ := c.getStringValue(body, "department") - position, _ := c.getStringValue(body, "position") - education, _ := c.getStringValue(body, "education") - nation, _ := c.getStringValue(body, "nation") - phone, _ := c.getStringValue(body, "phone") - wechat, _ := c.getStringValue(body, "wechat") - email, _ := c.getStringValue(body, "email") - homeAddress, _ := c.getStringValue(body, "home_address") - - row := models.BackendErpEmployee{ - Tid: nullableIntPtr(tid), - Account: account, - Password: hashEmployeePassword(password), - Name: name, - Gender: 0, - Birthday: parseDatePtr(birthday), - AffiliateUnit: strPtrIfNotEmpty(affiliateUnit), - Department: strPtrIfNotEmpty(department), - Position: strPtrIfNotEmpty(position), - Education: strPtrIfNotEmpty(education), - Nation: strPtrIfNotEmpty(nation), - Phone: strPtrIfNotEmpty(phone), - Wechat: strPtrIfNotEmpty(wechat), - Email: strPtrIfNotEmpty(email), - HomeAddress: strPtrIfNotEmpty(homeAddress), - AccountStatus: 1, - } - if hasGender { - row.Gender = int8(gender) - } - if hasStatus { - row.AccountStatus = int8(status) - } - - id, err := models.Orm.Insert(&row) - if err != nil { - c.jsonError(500, "创建员工失败: "+err.Error()) - return - } - - c.jsonOK(map[string]interface{}{"id": id}) -} - -// EditEmployee 更新员工。 -// POST /admin/erp/editEmployee/:id -func (c *BackendErpController) EditEmployee() { - id, ok := c.pathUint(":id") - if !ok { - c.jsonError(400, "无效ID") - return - } - - body := c.parseJSONBody() - update := orm.Params{} - - if v, has := c.getStringValue(body, "account"); has && strings.TrimSpace(v) != "" { - update["account"] = strings.TrimSpace(v) - } - if v, has := c.getStringValue(body, "name"); has { - v = strings.TrimSpace(v) - if v == "" { - c.jsonError(400, "姓名不能为空") - return - } - update["name"] = v - } - if v, has := c.getIntValue(body, "tid", "tenant_id"); has { - update["tid"] = nullableInt(v) - } - if v, has := c.getIntValue(body, "gender", "sex"); has { - update["gender"] = int8(v) - } - if v, has := c.getStringValue(body, "birthday"); has { - update["birthday"] = parseDatePtr(v) - } - if v, has := c.getStringValue(body, "affiliate_unit"); has { - update["affiliate_unit"] = nullableString(v) - } - if v, has := c.getStringValue(body, "department"); has { - update["department"] = nullableString(v) - } - if v, has := c.getStringValue(body, "position"); has { - update["position"] = nullableString(v) - } - if v, has := c.getStringValue(body, "education"); has { - update["education"] = nullableString(v) - } - if v, has := c.getStringValue(body, "nation"); has { - update["nation"] = nullableString(v) - } - if v, has := c.getStringValue(body, "phone"); has { - update["phone"] = nullableString(v) - } - if v, has := c.getStringValue(body, "wechat"); has { - update["wechat"] = nullableString(v) - } - if v, has := c.getStringValue(body, "email"); has { - update["email"] = nullableString(v) - } - if v, has := c.getStringValue(body, "home_address"); has { - update["home_address"] = nullableString(v) - } - if v, has := c.getIntValue(body, "account_status", "status"); has { - update["account_status"] = int8(v) - } - if v, has := c.getStringValue(body, "password"); has && strings.TrimSpace(v) != "" { - update["password"] = hashEmployeePassword(v) - } - - if len(update) == 0 { - c.jsonError(400, "无更新字段") - return - } - - num, err := models.Orm.QueryTable(new(models.BackendErpEmployee)). - Filter("id", id). - Filter("delete_time__isnull", true). - Update(update) - if err != nil { - c.jsonError(500, "更新员工失败: "+err.Error()) - return - } - if num == 0 { - c.jsonError(404, "员工不存在") - return - } - - c.jsonOK(nil) -} - -// DeleteEmployee 删除员工。 -// DELETE /admin/erp/deleteEmployee/:id -func (c *BackendErpController) DeleteEmployee() { - id, ok := c.pathUint(":id") - if !ok { - c.jsonError(400, "无效ID") - return - } - - num, err := models.Orm.QueryTable(new(models.BackendErpEmployee)). - Filter("id", id). - Filter("delete_time__isnull", true). - Update(orm.Params{"delete_time": c.nowString(), "account_status": int8(2)}) - if err != nil { - c.jsonError(500, "删除员工失败: "+err.Error()) - return - } - if num == 0 { - c.jsonError(404, "员工不存在") - return - } - - c.jsonOK(nil) -} - -// GetPosition 获取职位列表。 -// GET /admin/erp/getPosition -func (c *BackendErpController) GetPosition() { - tid, _ := c.GetInt("tid") - departmentID, _ := c.GetInt("department_id") - - qs := models.Orm.QueryTable(new(models.BackendErpPosition)) - if tid > 0 { - qs = qs.Filter("tid", tid) - } - if departmentID > 0 { - qs = qs.Filter("department_id", departmentID) - } - - var rows []models.BackendErpPosition - _, err := qs.OrderBy("sort", "id").All(&rows) - if err != nil { - c.jsonError(500, "查询职位失败: "+err.Error()) - return - } - - list := make([]erpPositionDTO, 0, len(rows)) - for _, row := range rows { - list = append(list, c.positionDTO(row)) - } - - c.jsonOK(list) -} - -// GetPositionDetail 获取职位详情。 -// GET /admin/erp/getPositionDetail/:id -func (c *BackendErpController) GetPositionDetail() { - id, ok := c.pathUint64(":id") - if !ok { - c.jsonError(400, "无效ID") - return - } - - var row models.BackendErpPosition - err := models.Orm.QueryTable(new(models.BackendErpPosition)).Filter("id", id).One(&row) - if err != nil { - c.jsonError(404, "职位不存在") - return - } - - c.jsonOK(c.positionDTO(row)) -} - -// CreatePosition 创建职位。 -// POST /admin/erp/createPosition -func (c *BackendErpController) CreatePosition() { - body := c.parseJSONBody() - - tenantID, _ := c.getUint64Value(body, "tenant_id", "tid") - departmentID, _ := c.getUint64Value(body, "department_id") - positionName, _ := c.getStringValue(body, "position_name", "name") - positionName = strings.TrimSpace(positionName) - if positionName == "" { - c.jsonError(400, "职位名称不能为空") - return - } - - positionCode, _ := c.getStringValue(body, "position_code", "code") - positionCode = strings.TrimSpace(positionCode) - if positionCode == "" { - positionCode = "POS" + c.nowCompactString() - } - positionType, _ := c.getIntValue(body, "position_type") - status, hasStatus := c.getIntValue(body, "status") - sortVal, _ := c.getUintValue(body, "sort") - - row := models.BackendErpPosition{ - Tid: tenantID, - DepartmentID: departmentID, - PositionCode: positionCode, - PositionName: positionName, - PositionType: int8(positionType), - Status: 1, - Sort: sortVal, - } - if hasStatus { - row.Status = int8(status) - } - - id, err := models.Orm.Insert(&row) - if err != nil { - c.jsonError(500, "创建职位失败: "+err.Error()) - return - } - - c.jsonOK(map[string]interface{}{"id": id}) -} - -// EditPosition 更新职位。 -// POST /admin/erp/editPosition/:id -func (c *BackendErpController) EditPosition() { - id, ok := c.pathUint64(":id") - if !ok { - c.jsonError(400, "无效ID") - return - } - - body := c.parseJSONBody() - update := orm.Params{} - - if v, has := c.getUint64Value(body, "tenant_id", "tid"); has { - update["tid"] = v - } - if v, has := c.getUint64Value(body, "department_id"); has { - update["department_id"] = v - } - if v, has := c.getStringValue(body, "position_code", "code"); has { - update["position_code"] = strings.TrimSpace(v) - } - if v, has := c.getStringValue(body, "position_name", "name"); has { - v = strings.TrimSpace(v) - if v == "" { - c.jsonError(400, "职位名称不能为空") - return - } - update["position_name"] = v - } - if v, has := c.getIntValue(body, "position_type"); has { - update["position_type"] = int8(v) - } - if v, has := c.getIntValue(body, "status"); has { - update["status"] = int8(v) - } - if v, has := c.getUintValue(body, "sort"); has { - update["sort"] = v - } - - if len(update) == 0 { - c.jsonError(400, "无更新字段") - return - } - - num, err := models.Orm.QueryTable(new(models.BackendErpPosition)).Filter("id", id).Update(update) - if err != nil { - c.jsonError(500, "更新职位失败: "+err.Error()) - return - } - if num == 0 { - c.jsonError(404, "职位不存在") - return - } - - c.jsonOK(nil) -} - -// DeletePosition 删除职位。 -// DELETE /admin/erp/deletePosition/:id -func (c *BackendErpController) DeletePosition() { - id, ok := c.pathUint64(":id") - if !ok { - c.jsonError(400, "无效ID") - return - } - - num, err := models.Orm.QueryTable(new(models.BackendErpPosition)).Filter("id", id).Delete() - if err != nil { - c.jsonError(500, "删除职位失败: "+err.Error()) - return - } - if num == 0 { - c.jsonError(404, "职位不存在") - return - } - - c.jsonOK(nil) -} - -func (c *BackendErpController) organizationDTO(row models.BackendErpOrganization) erpOrganizationDTO { - parentName := "" - if row.ParentID > 0 { - var parent models.BackendErpOrganization - if err := models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("id", row.ParentID). - One(&parent); err == nil { - parentName = parent.OrgName - } - } - - leaderID := uint64(0) - leaderName := "" - if row.LeaderID != nil { - leaderID = *row.LeaderID - var employee models.BackendErpEmployee - if err := models.Orm.QueryTable(new(models.BackendErpEmployee)). - Filter("id", leaderID). - One(&employee); err == nil { - leaderName = employee.Name - } - } - - return erpOrganizationDTO{ - ID: row.ID, - Tid: row.Tid, - TenantID: row.Tid, - OrgName: row.OrgName, - OrgCode: row.OrgCode, - ParentID: row.ParentID, - ParentName: parentName, - LeaderID: leaderID, - LeaderName: leaderName, - IsCompany: row.IsCompany, - Sort: row.Sort, - Status: row.Status, - Remark: derefString(row.Remark), - } -} - -func (c *BackendErpController) employeeDTO(row models.BackendErpEmployee) erpEmployeeDTO { - tid := 0 - if row.Tid != nil { - tid = *row.Tid - } - - birthday := "" - if row.Birthday != nil { - birthday = row.Birthday.Format("2006-01-02") - } - - affiliateUnit := derefString(row.AffiliateUnit) - department := derefString(row.Department) - affiliateUnitName := c.organizationNameByIDString(affiliateUnit) - departmentName := c.organizationNameByIDString(department) - - return erpEmployeeDTO{ - ID: row.ID, - Tid: tid, - TenantID: tid, - Account: row.Account, - Name: row.Name, - Gender: row.Gender, - Sex: row.Gender, - Birthday: birthday, - AffiliateUnit: affiliateUnit, - AffiliateUnitName: affiliateUnitName, - Department: department, - DepartmentName: departmentName, - Position: derefString(row.Position), - Education: derefString(row.Education), - Nation: derefString(row.Nation), - Phone: derefString(row.Phone), - Wechat: derefString(row.Wechat), - Email: derefString(row.Email), - HomeAddress: derefString(row.HomeAddress), - AccountStatus: row.AccountStatus, - Status: row.AccountStatus, - } -} - -func (c *BackendErpController) organizationNameByIDString(idValue string) string { - idValue = strings.TrimSpace(idValue) - if idValue == "" { - return "" - } - - id, err := strconv.ParseUint(idValue, 10, 64) - if err != nil || id == 0 { - return "" - } - - var org models.BackendErpOrganization - err = models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("id", id). - Filter("delete_time__isnull", true). - One(&org) - if err != nil { - return "" - } - - return org.OrgName -} - -func (c *BackendErpController) positionDTO(row models.BackendErpPosition) erpPositionDTO { - return erpPositionDTO{ - ID: row.ID, - TenantID: row.Tid, - Tid: row.Tid, - DepartmentID: row.DepartmentID, - PositionCode: row.PositionCode, - PositionName: row.PositionName, - PositionType: row.PositionType, - Status: row.Status, - Sort: row.Sort, - } -} - -func (c *BackendErpController) parseJSONBody() map[string]interface{} { - body := map[string]interface{}{} - contentType := strings.ToLower(c.Ctx.Input.Header("Content-Type")) - if !strings.Contains(contentType, "json") { - return body - } - if len(c.Ctx.Input.RequestBody) == 0 { - return body - } - _ = json.Unmarshal(c.Ctx.Input.RequestBody, &body) - return body -} - -func (c *BackendErpController) getStringValue(body map[string]interface{}, keys ...string) (string, bool) { - for _, key := range keys { - if v, ok := body[key]; ok { - switch val := v.(type) { - case string: - return val, true - case float64: - return strconv.FormatFloat(val, 'f', -1, 64), true - case bool: - return strconv.FormatBool(val), true - default: - return strings.TrimSpace(strings.Trim(strings.ReplaceAll(strings.ReplaceAll(toJSON(val), "\n", ""), "\r", ""), "\"")), true - } - } - if c.Ctx.Request.Form == nil && c.Ctx.Request.PostForm == nil && c.Ctx.Request.MultipartForm == nil { - _ = c.Ctx.Request.ParseMultipartForm(32 << 20) - } - if val := c.GetString(key); val != "" { - return val, true - } - } - return "", false -} - -func (c *BackendErpController) getIntValue(body map[string]interface{}, keys ...string) (int, bool) { - for _, key := range keys { - if v, ok := body[key]; ok { - switch val := v.(type) { - case float64: - return int(val), true - case int: - return val, true - case string: - if strings.TrimSpace(val) == "" { - return 0, true - } - parsed, err := strconv.Atoi(strings.TrimSpace(val)) - return parsed, err == nil - } - } - if c.Ctx.Request.Form == nil && c.Ctx.Request.PostForm == nil && c.Ctx.Request.MultipartForm == nil { - _ = c.Ctx.Request.ParseMultipartForm(32 << 20) - } - if val := c.GetString(key); val != "" { - parsed, err := strconv.Atoi(strings.TrimSpace(val)) - return parsed, err == nil - } - } - return 0, false -} - -func (c *BackendErpController) getUintValue(body map[string]interface{}, keys ...string) (uint, bool) { - v, ok := c.getIntValue(body, keys...) - if !ok || v < 0 { - return 0, ok - } - return uint(v), true -} - -func (c *BackendErpController) getUint64Value(body map[string]interface{}, keys ...string) (uint64, bool) { - for _, key := range keys { - if v, ok := body[key]; ok { - switch val := v.(type) { - case float64: - if val < 0 { - return 0, false - } - return uint64(val), true - case int: - if val < 0 { - return 0, false - } - return uint64(val), true - case string: - if strings.TrimSpace(val) == "" { - return 0, true - } - parsed, err := strconv.ParseUint(strings.TrimSpace(val), 10, 64) - return parsed, err == nil - } - } - if c.Ctx.Request.Form == nil && c.Ctx.Request.PostForm == nil && c.Ctx.Request.MultipartForm == nil { - _ = c.Ctx.Request.ParseMultipartForm(32 << 20) - } - if val := c.GetString(key); val != "" { - parsed, err := strconv.ParseUint(strings.TrimSpace(val), 10, 64) - return parsed, err == nil - } - } - return 0, false -} - -func (c *BackendErpController) pathUint(name string) (uint, bool) { - id, err := strconv.ParseUint(c.Ctx.Input.Param(name), 10, 64) - return uint(id), err == nil && id > 0 -} - -func (c *BackendErpController) pathUint64(name string) (uint64, bool) { - id, err := strconv.ParseUint(c.Ctx.Input.Param(name), 10, 64) - return id, err == nil && id > 0 -} - -func (c *BackendErpController) jsonOK(data interface{}) { - resp := map[string]interface{}{"code": 200, "msg": "success"} - if data != nil { - resp["data"] = data - } - c.Data["json"] = resp - _ = c.ServeJSON() -} - -func (c *BackendErpController) jsonError(code int, msg string) { - c.Data["json"] = map[string]interface{}{"code": code, "msg": msg} - _ = c.ServeJSON() -} - -func (c *BackendErpController) nowString() string { - return time.Now().Format("2006-01-02 15:04:05") -} - -func (c *BackendErpController) nowCompactString() string { - return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(c.nowString(), "-", ""), ":", ""), " ", "") -} - -func strPtrIfNotEmpty(v string) *string { - v = strings.TrimSpace(v) - if v == "" { - return nil - } - return &v -} - -func nullableString(v string) interface{} { - v = strings.TrimSpace(v) - if v == "" { - return nil - } - return v -} - -func nullableInt(v int) interface{} { - if v <= 0 { - return nil - } - return v -} - -func nullableIntPtr(v int) *int { - if v <= 0 { - return nil - } - return &v -} - -func nullableUint64(v uint64) interface{} { - if v == 0 { - return nil - } - return v -} - -func derefString(v *string) string { - if v == nil { - return "" - } - return *v -} - -func boolInt(v bool) int { - if v { - return 1 - } - return 0 -} - -func toJSON(v interface{}) string { - b, _ := json.Marshal(v) - return string(b) -} - -func parseDatePtr(v string) *time.Time { - v = strings.TrimSpace(v) - if v == "" { - return nil - } - if t, err := time.Parse("2006-01-02", v); err == nil { - return &t - } - if t, err := time.Parse("2006-01-02 15:04:05", v); err == nil { - return &t - } - return nil -} - -// hashEmployeePassword 适配 yz_backend_erp_employee.password varchar(64),使用 sha256 hex。 -// 如果密码为空则返回空字符串,符合表默认值。 -func hashEmployeePassword(plain string) string { - plain = strings.TrimSpace(plain) - if plain == "" { - return "" - } - sum := sha256.Sum256([]byte(plain)) - return hex.EncodeToString(sum[:]) -} - -// GetOrgSettings 获取组织架构设置 -// GET /backend/erp/getOrgSettings -func (c *BackendErpController) GetOrgSettings() { - tid, _ := c.GetInt("tid") - - settings := map[string]interface{}{ - "org_code_prefix": "ORG", - "employee_code_prefix": "EMP", - "position_code_prefix": "POS", - "auto_generate_codes": true, - "allow_duplicate_codes": false, - "max_org_levels": 10, - "max_org_children": 50, - "tid": tid, - } - - c.jsonOK(settings) -} - -// SaveOrgSettings 保存组织架构设置 -// POST /backend/erp/saveOrgSettings -func (c *BackendErpController) SaveOrgSettings() { - _ = c.parseJSONBody() - - settings := map[string]interface{}{ - "org_code_prefix": "ORG", - "employee_code_prefix": "EMP", - "position_code_prefix": "POS", - "auto_generate_codes": true, - "allow_duplicate_codes": false, - "max_org_levels": 10, - "max_org_children": 50, - } - - // 这里可以添加保存到数据库的逻辑 - // 目前只是返回成功响应 - - c.jsonOK(settings) -} - -// GetOrganizationTree 获取组织架构树形结构 -// GET /backend/erp/getOrganizationTree -func (c *BackendErpController) GetOrganizationTree() { - tid, _ := c.GetInt("tid") - - qs := models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("delete_time__isnull", true). - Exclude("status", 0) - if tid > 0 { - qs = qs.Filter("tid", tid) - } - - var rows []models.BackendErpOrganization - _, err := qs.OrderBy("sort", "id").All(&rows) - if err != nil { - c.jsonError(500, "查询组织机构失败: "+err.Error()) - return - } - - // 构建树形结构 - tree := buildOrganizationTree(rows) - c.jsonOK(tree) -} - -// SearchOrganizations 搜索组织机构 -// GET /backend/erp/searchOrganizations -func (c *BackendErpController) SearchOrganizations() { - tid, _ := c.GetInt("tid") - keyword := c.GetString("keyword") - - qs := models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("delete_time__isnull", true). - Exclude("status", 0) - if tid > 0 { - qs = qs.Filter("tid", tid) - } - - if keyword != "" { - qs = qs.Filter("org_name__icontains", keyword) - } - - var rows []models.BackendErpOrganization - _, err := qs.OrderBy("sort", "id").All(&rows) - if err != nil { - c.jsonError(500, "查询组织机构失败: "+err.Error()) - return - } - - list := make([]erpOrganizationDTO, 0, len(rows)) - for _, row := range rows { - list = append(list, c.organizationDTO(row)) - } - - c.jsonOK(list) -} - -// GetOrganizationEmployees 获取指定组织下的员工列表 -// GET /backend/erp/getOrganizationEmployees/:org_id -func (c *BackendErpController) GetOrganizationEmployees() { - orgID, ok := c.pathUint64(":org_id") - if !ok { - c.jsonError(400, "无效组织ID") - return - } - - tid, _ := c.GetInt("tid") - - // 获取该组织及其所有子组织的ID - var orgIDs []uint64 - orgIDs = append(orgIDs, orgID) - - // 递归获取子组织ID - getChildOrgIDs(orgID, &orgIDs) - - qs := models.Orm.QueryTable(new(models.BackendErpEmployee)). - Filter("delete_time__isnull", true) - if tid > 0 { - qs = qs.Filter("tid", tid) - } - - // 查询属于这些组织的员工 - if len(orgIDs) > 0 { - qs = qs.Filter("department__in", orgIDs) - } - - var rows []models.BackendErpEmployee - _, err := qs.OrderBy("-id").All(&rows) - if err != nil { - c.jsonError(500, "查询员工失败: "+err.Error()) - return - } - - list := make([]erpEmployeeDTO, 0, len(rows)) - for _, row := range rows { - list = append(list, c.employeeDTO(row)) - } - - c.jsonOK(list) -} - -// MoveEmployeeToOrg 移动员工到指定组织 -// POST /backend/erp/moveEmployeeToOrg -func (c *BackendErpController) MoveEmployeeToOrg() { - body := c.parseJSONBody() - - employeeID, hasEmployee := c.getUintValue(body, "employee_id") - orgID, hasOrg := c.getUint64Value(body, "org_id") - - if !hasEmployee || !hasOrg { - c.jsonError(400, "员工ID和组织ID不能为空") - return - } - - // 更新员工的部门信息 - update := orm.Params{"department": orgID} - num, err := models.Orm.QueryTable(new(models.BackendErpEmployee)). - Filter("id", employeeID). - Filter("delete_time__isnull", true). - Update(update) - if err != nil { - c.jsonError(500, "移动员工失败: "+err.Error()) - return - } - if num == 0 { - c.jsonError(404, "员工不存在") - return - } - - c.jsonOK(nil) -} - -// GetOrganizationHierarchy 获取组织层级结构 -// GET /backend/erp/getOrganizationHierarchy/:org_id -func (c *BackendErpController) GetOrganizationHierarchy() { - orgID, ok := c.pathUint64(":org_id") - if !ok { - c.jsonError(400, "无效组织ID") - return - } - - // 获取指定组织的信息 - var org models.BackendErpOrganization - err := models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("id", orgID). - Filter("delete_time__isnull", true). - One(&org) - if err != nil { - c.jsonError(404, "组织不存在") - return - } - - // 获取所有子组织 - var children []models.BackendErpOrganization - _, err = models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("parent_id", orgID). - Filter("delete_time__isnull", true). - OrderBy("sort", "id"). - All(&children) - if err != nil { - c.jsonError(500, "查询子组织失败: "+err.Error()) - return - } - - hierarchy := map[string]interface{}{ - "current": c.organizationDTO(org), - "children": make([]erpOrganizationDTO, 0, len(children)), - } - - childList := make([]erpOrganizationDTO, len(children)) - for i, child := range children { - childList[i] = c.organizationDTO(child) - } - hierarchy["children"] = childList - - c.jsonOK(hierarchy) -} - -// CheckOrgCodeUnique 检查组织编码唯一性 -// GET /backend/erp/checkOrgCodeUnique -func (c *BackendErpController) CheckOrgCodeUnique() { - tid, _ := c.GetInt("tid") - orgCode := c.GetString("org_code") - orgID, _ := c.GetUint64("org_id") - - if orgCode == "" { - c.jsonError(400, "组织编码不能为空") - return - } - - qs := models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("org_code", orgCode). - Filter("delete_time__isnull", true) - if tid > 0 { - qs = qs.Filter("tid", tid) - } - if orgID > 0 { - qs = qs.Exclude("id", orgID) - } - - count, err := qs.Count() - if err != nil { - c.jsonError(500, "检查编码唯一性失败: "+err.Error()) - return - } - - c.jsonOK(map[string]interface{}{ - "unique": count == 0, - "message": func() string { - if count == 0 { - return "编码可用" - } - return "编码已存在" - }(), - }) -} - -// CheckEmployeeAccountUnique 检查员工账号唯一性 -// GET /backend/erp/checkEmployeeAccountUnique -func (c *BackendErpController) CheckEmployeeAccountUnique() { - tid, _ := c.GetInt("tid") - account := c.GetString("account") - employeeID, _ := c.GetInt("employee_id") - - if account == "" { - c.jsonError(400, "员工账号不能为空") - return - } - - qs := models.Orm.QueryTable(new(models.BackendErpEmployee)). - Filter("account", account). - Filter("delete_time__isnull", true) - if tid > 0 { - qs = qs.Filter("tid", tid) - } - if employeeID > 0 { - qs = qs.Exclude("id", employeeID) - } - - count, err := qs.Count() - if err != nil { - c.jsonError(500, "检查账号唯一性失败: "+err.Error()) - return - } - - c.jsonOK(map[string]interface{}{ - "unique": count == 0, - "message": func() string { - if count == 0 { - return "账号可用" - } - return "账号已存在" - }(), - }) -} - -// buildOrganizationTree 构建组织树形结构 -func buildOrganizationTree(rows []models.BackendErpOrganization) []map[string]interface{} { - tree := make([]map[string]interface{}, 0) - nodeMap := make(map[uint64]map[string]interface{}) - - // 创建节点映射 - for _, row := range rows { - node := map[string]interface{}{ - "id": row.ID, - "tid": row.Tid, - "org_name": row.OrgName, - "org_code": row.OrgCode, - "parent_id": row.ParentID, - "sort": row.Sort, - "is_company": row.IsCompany, - "status": row.Status, - "leader_id": row.LeaderID, - "remark": row.Remark, - "children": make([]map[string]interface{}, 0), - } - nodeMap[row.ID] = node - } - - // 构建树形结构 - for _, row := range rows { - node := nodeMap[row.ID] - if row.ParentID == 0 { - tree = append(tree, node) - } else { - if parent, exists := nodeMap[row.ParentID]; exists { - parent["children"] = append(parent["children"].([]map[string]interface{}), node) - } - } - } - - return tree -} - -// getChildOrgIDs 递归获取子组织ID -func getChildOrgIDs(parentID uint64, orgIDs *[]uint64) { - var children []models.BackendErpOrganization - _, err := models.Orm.QueryTable(new(models.BackendErpOrganization)). - Filter("parent_id", parentID). - Filter("delete_time__isnull", true). - All(&children) - if err != nil { - return - } - - for _, child := range children { - *orgIDs = append(*orgIDs, child.ID) - getChildOrgIDs(child.ID, orgIDs) - } -} diff --git a/go/controllers/backend_organization.go b/go/controllers/backend_organization.go new file mode 100644 index 0000000..b293441 --- /dev/null +++ b/go/controllers/backend_organization.go @@ -0,0 +1,1450 @@ +package controllers + +import ( + "fmt" + "strconv" + "strings" + + "server/models" + "server/pkg/jwtutil" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// BackendOrganizationController 组织架构(组织、员工、职位)接口。 +// +// 组织架构是租户端的通用基础数据:进销存(ERP)与办公自动化(OA)两个模块各自有独立界面, +// 但读写的是同一份数据。所有查询与写入都强制带上 JWT 中的租户ID(tid),实现租户间隔离, +// 前端传入的 tid / tenant_id 一律忽略,避免越权读取其它租户的数据。 +type BackendOrganizationController struct { + beego.Controller +} + +const orgSettingsCodePrefix = "backend_org_settings" + +type orgSettings struct { + OrgCodePrefix string `json:"org_code_prefix"` + EmployeeCodePrefix string `json:"employee_code_prefix"` + PositionCodePrefix string `json:"position_code_prefix"` + AutoGenerateCodes bool `json:"auto_generate_codes"` + CodeLength int `json:"code_length"` + DefaultOrgType int `json:"default_org_type"` + DefaultSort int `json:"default_sort"` + DefaultStatus int `json:"default_status"` + MaxOrgLevels int `json:"max_org_levels"` + MaxOrgChildren int `json:"max_org_children"` + AllowDuplicateCode bool `json:"allow_duplicate_codes"` + BatchOperations bool `json:"batch_operations"` + ExportEnabled bool `json:"export_enabled"` + ImportEnabled bool `json:"import_enabled"` +} + +func defaultOrgSettings() orgSettings { + return orgSettings{ + OrgCodePrefix: "ORG", + EmployeeCodePrefix: "EMP", + PositionCodePrefix: "POS", + AutoGenerateCodes: true, + CodeLength: 8, + DefaultOrgType: 0, + DefaultSort: 0, + DefaultStatus: 1, + MaxOrgLevels: 10, + MaxOrgChildren: 50, + AllowDuplicateCode: false, + BatchOperations: true, + ExportEnabled: true, + ImportEnabled: true, + } +} + +type organizationDTO struct { + ID uint64 `json:"id"` + Tid uint64 `json:"tid"` + TenantID uint64 `json:"tenant_id"` + OrgName string `json:"org_name"` + OrgCode string `json:"org_code"` + ParentID uint64 `json:"parent_id"` + ParentName string `json:"parent_name"` + LeaderID uint64 `json:"leader_id"` + LeaderName string `json:"leader_name"` + IsCompany int `json:"is_company"` + Sort uint `json:"sort"` + Status int8 `json:"status"` + Remark string `json:"remark"` + EmployeeCount int64 `json:"employee_count"` + CreateTime string `json:"create_time"` + UpdateTime string `json:"update_time"` +} + +type employeeDTO struct { + ID uint `json:"id"` + Tid int `json:"tid"` + TenantID int `json:"tenant_id"` + Account string `json:"account"` + Name string `json:"name"` + Gender int8 `json:"gender"` + Sex int8 `json:"sex"` + Birthday string `json:"birthday"` + AffiliateUnit string `json:"affiliate_unit"` + AffiliateUnitName string `json:"affiliate_unit_name"` + Department string `json:"department"` + DepartmentName string `json:"department_name"` + Position string `json:"position"` + Education string `json:"education"` + Nation string `json:"nation"` + Phone string `json:"phone"` + Wechat string `json:"wechat"` + Email string `json:"email"` + HomeAddress string `json:"home_address"` + AccountStatus int8 `json:"account_status"` + Status int8 `json:"status"` + CreateTime string `json:"create_time"` +} + +type positionDTO struct { + ID uint64 `json:"id"` + Tid uint64 `json:"tid"` + TenantID uint64 `json:"tenant_id"` + DepartmentID uint64 `json:"department_id"` + DepartmentName string `json:"department_name"` + PositionCode string `json:"position_code"` + PositionName string `json:"position_name"` + PositionType int8 `json:"position_type"` + Status int8 `json:"status"` + Sort uint `json:"sort"` + Remark string `json:"remark"` + CreateTime string `json:"create_time"` +} + +// --------------------------------------------------------------------------- +// 认证与响应 +// --------------------------------------------------------------------------- + +// claims 解析 Authorization 头中的 JWT,要求是租户端(backend)用户且带有效租户ID。 +func (c *BackendOrganizationController) claims() (*jwtutil.Claims, bool) { + auth := strings.TrimSpace(c.Ctx.Request.Header.Get("Authorization")) + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, false + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil || claims.UserType != "backend" || claims.TenantId <= 0 { + return nil, false + } + return claims, true +} + +// tenantID 取当前登录租户ID,未通过认证时直接输出 401 并返回 false。 +func (c *BackendOrganizationController) tenantID() (uint64, bool) { + claims, ok := c.claims() + if !ok { + c.jsonError(401, "未登录或登录已过期") + return 0, false + } + return uint64(claims.TenantId), true +} + +func (c *BackendOrganizationController) jsonOK(data interface{}) { + resp := map[string]interface{}{"code": 200, "msg": "success"} + if data != nil { + resp["data"] = data + } + c.Data["json"] = resp + _ = c.ServeJSON() +} + +func (c *BackendOrganizationController) jsonError(code int, msg string) { + if code == 401 { + c.Ctx.Output.SetStatus(401) + } + c.Data["json"] = map[string]interface{}{"code": code, "msg": msg} + _ = c.ServeJSON() +} + +// --------------------------------------------------------------------------- +// 查询基座:所有 QuerySeter 都强制附加 tid 过滤 +// --------------------------------------------------------------------------- + +func (c *BackendOrganizationController) orgQuery(tid uint64) orm.QuerySeter { + return models.Orm.QueryTable(new(models.BackendOrganization)). + Filter("tid", tid). + Filter("delete_time__isnull", true) +} + +func (c *BackendOrganizationController) employeeQuery(tid uint64) orm.QuerySeter { + return models.Orm.QueryTable(new(models.BackendEmployee)). + Filter("tid", tid). + Filter("delete_time__isnull", true) +} + +func (c *BackendOrganizationController) positionQuery(tid uint64) orm.QuerySeter { + return models.Orm.QueryTable(new(models.BackendPosition)). + Filter("tid", tid). + Filter("delete_time__isnull", true) +} + +// --------------------------------------------------------------------------- +// 组织机构 +// --------------------------------------------------------------------------- + +// GetOrganization 获取组织机构列表(扁平)。 +// GET /backend/{erp|oa}/getOrganization +func (c *BackendOrganizationController) GetOrganization() { + tid, ok := c.tenantID() + if !ok { + return + } + + qs := c.orgQuery(tid).Exclude("status", 0) + if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" { + qs = qs.Filter("org_name__icontains", keyword) + } + + var rows []models.BackendOrganization + if _, err := qs.OrderBy("sort", "id").All(&rows); err != nil { + c.jsonError(500, "查询组织机构失败: "+err.Error()) + return + } + + c.jsonOK(c.organizationDTOList(tid, rows)) +} + +// GetOrganizationDetail 获取组织机构详情。 +// GET /backend/{erp|oa}/getOrganizationDetail/:id +func (c *BackendOrganizationController) GetOrganizationDetail() { + tid, ok := c.tenantID() + if !ok { + return + } + id, valid := c.pathUint64(":id") + if !valid { + c.jsonError(400, "无效ID") + return + } + + var row models.BackendOrganization + if err := c.orgQuery(tid).Filter("id", id).Exclude("status", 0).One(&row); err != nil { + c.jsonError(404, "组织机构不存在") + return + } + + c.jsonOK(c.organizationDTO(tid, row)) +} + +// CreateOrganization 创建组织机构。 +// POST /backend/{erp|oa}/createOrganization +func (c *BackendOrganizationController) CreateOrganization() { + tid, ok := c.tenantID() + if !ok { + return + } + body := c.parseJSONBody() + settings := c.loadOrgSettings(tid) + + orgName, _ := c.getStringValue(body, "org_name", "name") + orgName = strings.TrimSpace(orgName) + if orgName == "" { + c.jsonError(400, "组织名称不能为空") + return + } + + parentID, _ := c.getUint64Value(body, "parent_id") + if parentID > 0 { + if !c.orgExists(tid, parentID) { + c.jsonError(400, "上级组织不存在") + return + } + depth, err := c.orgDepth(tid, parentID) + if err != nil { + c.jsonError(500, "校验组织层级失败: "+err.Error()) + return + } + if settings.MaxOrgLevels > 0 && depth+1 > settings.MaxOrgLevels { + c.jsonError(400, fmt.Sprintf("组织层级最多 %d 级", settings.MaxOrgLevels)) + return + } + if settings.MaxOrgChildren > 0 { + count, err := c.orgQuery(tid).Filter("parent_id", parentID).Exclude("status", 0).Count() + if err == nil && int(count) >= settings.MaxOrgChildren { + c.jsonError(400, fmt.Sprintf("同一上级下最多 %d 个子组织", settings.MaxOrgChildren)) + return + } + } + } + + orgCode, _ := c.getStringValue(body, "org_code", "code") + orgCode = strings.TrimSpace(orgCode) + if orgCode == "" { + if !settings.AutoGenerateCodes { + c.jsonError(400, "组织编码不能为空") + return + } + orgCode = c.generateCode(settings.OrgCodePrefix, settings.CodeLength) + } + if !settings.AllowDuplicateCode { + count, err := c.orgQuery(tid).Filter("org_code", orgCode).Count() + if err != nil { + c.jsonError(500, "校验组织编码失败: "+err.Error()) + return + } + if count > 0 { + c.jsonError(400, "组织编码已存在") + return + } + } + + leaderID, hasLeader := c.getUint64Value(body, "leader_id") + sortVal, hasSort := c.getUintValue(body, "sort") + isCompany, hasCompany := c.getIntValue(body, "is_company") + status, hasStatus := c.getIntValue(body, "status") + remark, _ := c.getStringValue(body, "remark") + + row := models.BackendOrganization{ + Tid: tid, + OrgName: orgName, + OrgCode: orgCode, + ParentID: parentID, + Sort: uint(settings.DefaultSort), + IsCompany: boolInt(parentID == 0), + Status: int8(settings.DefaultStatus), + Remark: strPtrIfNotEmpty(remark), + } + if hasSort { + row.Sort = sortVal + } + if hasLeader && leaderID > 0 { + row.LeaderID = &leaderID + } + if hasCompany { + row.IsCompany = isCompany + } + if hasStatus { + row.Status = int8(status) + } + + id, err := models.Orm.Insert(&row) + if err != nil { + c.jsonError(500, "创建组织机构失败: "+err.Error()) + return + } + + c.jsonOK(map[string]interface{}{"id": id, "org_code": orgCode}) +} + +// EditOrganization 更新组织机构。 +// POST /backend/{erp|oa}/editOrganization/:id +func (c *BackendOrganizationController) EditOrganization() { + tid, ok := c.tenantID() + if !ok { + return + } + id, valid := c.pathUint64(":id") + if !valid { + c.jsonError(400, "无效ID") + return + } + if !c.orgExists(tid, id) { + c.jsonError(404, "组织机构不存在") + return + } + + body := c.parseJSONBody() + settings := c.loadOrgSettings(tid) + update := orm.Params{} + + if v, has := c.getStringValue(body, "org_name", "name"); has { + v = strings.TrimSpace(v) + if v == "" { + c.jsonError(400, "组织名称不能为空") + return + } + update["org_name"] = v + } + if v, has := c.getStringValue(body, "org_code", "code"); has { + v = strings.TrimSpace(v) + if v == "" { + c.jsonError(400, "组织编码不能为空") + return + } + if !settings.AllowDuplicateCode { + count, err := c.orgQuery(tid).Filter("org_code", v).Exclude("id", id).Count() + if err != nil { + c.jsonError(500, "校验组织编码失败: "+err.Error()) + return + } + if count > 0 { + c.jsonError(400, "组织编码已存在") + return + } + } + update["org_code"] = v + } + if v, has := c.getUint64Value(body, "parent_id"); has { + if err := c.validateParentChange(tid, id, v, settings); err != nil { + c.jsonError(400, err.Error()) + return + } + update["parent_id"] = v + } + if v, has := c.getUint64Value(body, "leader_id"); has { + update["leader_id"] = nullableUint64(v) + } + if v, has := c.getUintValue(body, "sort"); has { + update["sort"] = v + } + if v, has := c.getIntValue(body, "is_company"); has { + update["is_company"] = v + } + if v, has := c.getIntValue(body, "status"); has { + update["status"] = int8(v) + } + if v, has := c.getStringValue(body, "remark"); has { + update["remark"] = nullableString(v) + } + + if len(update) == 0 { + c.jsonError(400, "无更新字段") + return + } + + if _, err := c.orgQuery(tid).Filter("id", id).Update(update); err != nil { + c.jsonError(500, "更新组织机构失败: "+err.Error()) + return + } + + c.jsonOK(nil) +} + +// DeleteOrganization 删除组织机构(软删除)。 +// DELETE /backend/{erp|oa}/deleteOrganization/:id +func (c *BackendOrganizationController) DeleteOrganization() { + tid, ok := c.tenantID() + if !ok { + return + } + id, valid := c.pathUint64(":id") + if !valid { + c.jsonError(400, "无效ID") + return + } + + childCount, err := c.orgQuery(tid).Filter("parent_id", id).Exclude("status", 0).Count() + if err != nil { + c.jsonError(500, "检查子组织失败: "+err.Error()) + return + } + if childCount > 0 { + c.jsonError(400, "请先删除下级组织") + return + } + + employeeCount, err := c.employeeQuery(tid).Filter("department", strconv.FormatUint(id, 10)).Count() + if err != nil { + c.jsonError(500, "检查组织员工失败: "+err.Error()) + return + } + if employeeCount > 0 { + c.jsonError(400, "该组织下还有员工,请先调岗或删除员工") + return + } + + num, err := c.orgQuery(tid).Filter("id", id). + Update(orm.Params{"delete_time": c.nowString(), "status": int8(0)}) + if err != nil { + c.jsonError(500, "删除组织机构失败: "+err.Error()) + return + } + if num == 0 { + c.jsonError(404, "组织机构不存在") + return + } + + c.jsonOK(nil) +} + +// GetCompanys 获取企业单位列表。 +// GET /backend/{erp|oa}/getCompanys +func (c *BackendOrganizationController) GetCompanys() { + tid, ok := c.tenantID() + if !ok { + return + } + + var rows []models.BackendOrganization + _, err := c.orgQuery(tid).Exclude("status", 0).Filter("is_company", 1). + OrderBy("sort", "id").All(&rows) + if err != nil { + c.jsonError(500, "查询企业单位失败: "+err.Error()) + return + } + + c.jsonOK(c.organizationDTOList(tid, rows)) +} + +// GetDepartments 获取部门列表。 +// GET /backend/{erp|oa}/getDepartments?parent_id=1 +func (c *BackendOrganizationController) GetDepartments() { + tid, ok := c.tenantID() + if !ok { + return + } + parentID, _ := c.GetUint64("parent_id") + + qs := c.orgQuery(tid).Exclude("status", 0) + if parentID > 0 { + qs = qs.Filter("parent_id", parentID) + } else { + qs = qs.Filter("is_company", 0) + } + + var rows []models.BackendOrganization + if _, err := qs.OrderBy("sort", "id").All(&rows); err != nil { + c.jsonError(500, "查询部门失败: "+err.Error()) + return + } + + c.jsonOK(c.organizationDTOList(tid, rows)) +} + +// GetOrganizationTree 获取组织架构树。 +// GET /backend/{erp|oa}/getOrganizationTree +func (c *BackendOrganizationController) GetOrganizationTree() { + tid, ok := c.tenantID() + if !ok { + return + } + + var rows []models.BackendOrganization + _, err := c.orgQuery(tid).Exclude("status", 0).OrderBy("sort", "id").All(&rows) + if err != nil { + c.jsonError(500, "查询组织机构失败: "+err.Error()) + return + } + + c.jsonOK(buildOrganizationTree(c.organizationDTOList(tid, rows))) +} + +// SearchOrganizations 按名称/编码搜索组织机构。 +// GET /backend/{erp|oa}/searchOrganizations?keyword=xx +func (c *BackendOrganizationController) SearchOrganizations() { + tid, ok := c.tenantID() + if !ok { + return + } + keyword := strings.TrimSpace(c.GetString("keyword")) + + cond := orm.NewCondition(). + And("tid", tid). + And("delete_time__isnull", true). + AndNot("status", 0) + if keyword != "" { + cond = cond.AndCond(orm.NewCondition(). + Or("org_name__icontains", keyword). + Or("org_code__icontains", keyword)) + } + + var rows []models.BackendOrganization + _, err := models.Orm.QueryTable(new(models.BackendOrganization)). + SetCond(cond).OrderBy("sort", "id").All(&rows) + if err != nil { + c.jsonError(500, "查询组织机构失败: "+err.Error()) + return + } + + c.jsonOK(c.organizationDTOList(tid, rows)) +} + +// GetOrganizationHierarchy 获取指定组织的上级链与直接下级。 +// GET /backend/{erp|oa}/getOrganizationHierarchy/:org_id +func (c *BackendOrganizationController) GetOrganizationHierarchy() { + tid, ok := c.tenantID() + if !ok { + return + } + orgID, valid := c.pathUint64(":org_id") + if !valid { + c.jsonError(400, "无效组织ID") + return + } + + var org models.BackendOrganization + if err := c.orgQuery(tid).Filter("id", orgID).One(&org); err != nil { + c.jsonError(404, "组织不存在") + return + } + + var children []models.BackendOrganization + if _, err := c.orgQuery(tid).Filter("parent_id", orgID).Exclude("status", 0). + OrderBy("sort", "id").All(&children); err != nil { + c.jsonError(500, "查询子组织失败: "+err.Error()) + return + } + + // 自底向上收集上级链,最多回溯 64 层,防止脏数据造成死循环 + ancestors := make([]organizationDTO, 0) + parentID := org.ParentID + for i := 0; i < 64 && parentID > 0; i++ { + var parent models.BackendOrganization + if err := c.orgQuery(tid).Filter("id", parentID).One(&parent); err != nil { + break + } + ancestors = append([]organizationDTO{c.organizationDTO(tid, parent)}, ancestors...) + parentID = parent.ParentID + } + + c.jsonOK(map[string]interface{}{ + "current": c.organizationDTO(tid, org), + "ancestors": ancestors, + "children": c.organizationDTOList(tid, children), + }) +} + +// GetOrganizationStats 组织架构统计。 +// GET /backend/{erp|oa}/getOrganizationStats +func (c *BackendOrganizationController) GetOrganizationStats() { + tid, ok := c.tenantID() + if !ok { + return + } + + var rows []models.BackendOrganization + if _, err := c.orgQuery(tid).All(&rows); err != nil { + c.jsonError(500, "统计组织架构失败: "+err.Error()) + return + } + + childrenOf := map[uint64][]uint64{} + for _, row := range rows { + childrenOf[row.ParentID] = append(childrenOf[row.ParentID], row.ID) + } + + companyCount, departmentCount, enabledCount := 0, 0, 0 + for _, row := range rows { + if row.IsCompany == 1 { + companyCount++ + } else { + departmentCount++ + } + if row.Status == 1 { + enabledCount++ + } + } + + employeeTotal, _ := c.employeeQuery(tid).Count() + employeeActive, _ := c.employeeQuery(tid).Filter("account_status", 1).Count() + positionTotal, _ := c.positionQuery(tid).Count() + + c.jsonOK(map[string]interface{}{ + "org_total": len(rows), + "company_count": companyCount, + "department_count": departmentCount, + "enabled_count": enabledCount, + "disabled_count": len(rows) - enabledCount, + "max_depth": treeDepth(childrenOf, 0, 0), + "employee_total": employeeTotal, + "employee_active": employeeActive, + "position_total": positionTotal, + }) +} + +// MoveOrganization 移动组织节点到新的上级(拖拽调整层级)。 +// POST /backend/{erp|oa}/moveOrganization +func (c *BackendOrganizationController) MoveOrganization() { + tid, ok := c.tenantID() + if !ok { + return + } + body := c.parseJSONBody() + + orgID, hasOrg := c.getUint64Value(body, "org_id", "id") + if !hasOrg || orgID == 0 { + c.jsonError(400, "组织ID不能为空") + return + } + parentID, _ := c.getUint64Value(body, "parent_id") + + if !c.orgExists(tid, orgID) { + c.jsonError(404, "组织不存在") + return + } + settings := c.loadOrgSettings(tid) + if err := c.validateParentChange(tid, orgID, parentID, settings); err != nil { + c.jsonError(400, err.Error()) + return + } + + update := orm.Params{"parent_id": parentID, "is_company": boolInt(parentID == 0)} + if v, has := c.getUintValue(body, "sort"); has { + update["sort"] = v + } + if _, err := c.orgQuery(tid).Filter("id", orgID).Update(update); err != nil { + c.jsonError(500, "移动组织失败: "+err.Error()) + return + } + + c.jsonOK(nil) +} + +// BatchOrganizeOrganizations 批量启用/禁用/删除组织。 +// POST /backend/{erp|oa}/batchOrganizeOrganizations +func (c *BackendOrganizationController) BatchOrganizeOrganizations() { + tid, ok := c.tenantID() + if !ok { + return + } + body := c.parseJSONBody() + + ids := c.getUint64Slice(body, "ids") + if len(ids) == 0 { + c.jsonError(400, "请选择要操作的组织") + return + } + action, _ := c.getStringValue(body, "action") + action = strings.ToLower(strings.TrimSpace(action)) + + var update orm.Params + switch action { + case "enable": + update = orm.Params{"status": int8(1)} + case "disable": + update = orm.Params{"status": int8(0)} + case "delete": + // 有下级或有员工的组织不允许批量删除,避免产生孤儿数据 + for _, id := range ids { + childCount, _ := c.orgQuery(tid).Filter("parent_id", id).Exclude("status", 0).Count() + if childCount > 0 { + c.jsonError(400, "选中组织存在下级组织,无法批量删除") + return + } + employeeCount, _ := c.employeeQuery(tid).Filter("department", strconv.FormatUint(id, 10)).Count() + if employeeCount > 0 { + c.jsonError(400, "选中组织下仍有员工,无法批量删除") + return + } + } + update = orm.Params{"delete_time": c.nowString(), "status": int8(0)} + default: + c.jsonError(400, "不支持的操作类型") + return + } + + num, err := c.orgQuery(tid).Filter("id__in", ids).Update(update) + if err != nil { + c.jsonError(500, "批量操作失败: "+err.Error()) + return + } + + c.jsonOK(map[string]interface{}{"affected": num}) +} + +// CheckOrgCodeUnique 检查组织编码唯一性。 +// GET /backend/{erp|oa}/checkOrgCodeUnique?org_code=xx&exclude_id=1 +func (c *BackendOrganizationController) CheckOrgCodeUnique() { + tid, ok := c.tenantID() + if !ok { + return + } + orgCode := strings.TrimSpace(c.GetString("org_code")) + if orgCode == "" { + c.jsonError(400, "组织编码不能为空") + return + } + excludeID, _ := c.GetUint64("exclude_id") + if excludeID == 0 { + excludeID, _ = c.GetUint64("org_id") + } + + qs := c.orgQuery(tid).Filter("org_code", orgCode) + if excludeID > 0 { + qs = qs.Exclude("id", excludeID) + } + + count, err := qs.Count() + if err != nil { + c.jsonError(500, "检查编码唯一性失败: "+err.Error()) + return + } + + c.jsonOK(map[string]interface{}{"unique": count == 0, "message": uniqueMessage(count == 0, "编码")}) +} + +// --------------------------------------------------------------------------- +// 员工 +// --------------------------------------------------------------------------- + +// GetEmployee 获取员工列表,支持按组织、关键词、状态过滤。 +// GET /backend/{erp|oa}/getEmployee?org_id=1&keyword=xx&status=1 +func (c *BackendOrganizationController) GetEmployee() { + tid, ok := c.tenantID() + if !ok { + return + } + + cond := orm.NewCondition().And("tid", tid).And("delete_time__isnull", true) + + orgID, _ := c.GetUint64("org_id") + if orgID == 0 { + orgID, _ = c.GetUint64("department_id") + } + if orgID > 0 { + // 含下级组织:把整棵子树的组织ID都算进去 + orgIDs := c.collectOrgIDs(tid, orgID) + values := make([]string, 0, len(orgIDs)) + for _, id := range orgIDs { + values = append(values, strconv.FormatUint(id, 10)) + } + cond = cond.And("department__in", values) + } + if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" { + cond = cond.AndCond(orm.NewCondition(). + Or("name__icontains", keyword). + Or("account__icontains", keyword). + Or("phone__icontains", keyword)) + } + if raw := strings.TrimSpace(c.GetString("status")); raw != "" { + if status, err := strconv.Atoi(raw); err == nil { + cond = cond.And("account_status", int8(status)) + } + } + + var rows []models.BackendEmployee + _, err := models.Orm.QueryTable(new(models.BackendEmployee)). + SetCond(cond).OrderBy("-id").All(&rows) + if err != nil { + c.jsonError(500, "查询员工失败: "+err.Error()) + return + } + + c.jsonOK(c.employeeDTOList(tid, rows)) +} + +// GetOrganizationEmployees 获取指定组织(含下级)的员工列表。 +// GET /backend/{erp|oa}/getOrganizationEmployees/:org_id +func (c *BackendOrganizationController) GetOrganizationEmployees() { + tid, ok := c.tenantID() + if !ok { + return + } + orgID, valid := c.pathUint64(":org_id") + if !valid { + c.jsonError(400, "无效组织ID") + return + } + + orgIDs := c.collectOrgIDs(tid, orgID) + values := make([]string, 0, len(orgIDs)) + for _, id := range orgIDs { + values = append(values, strconv.FormatUint(id, 10)) + } + + var rows []models.BackendEmployee + _, err := c.employeeQuery(tid).Filter("department__in", values).OrderBy("-id").All(&rows) + if err != nil { + c.jsonError(500, "查询员工失败: "+err.Error()) + return + } + + c.jsonOK(c.employeeDTOList(tid, rows)) +} + +// GetEmployeeDetail 获取员工详情。 +// GET /backend/{erp|oa}/getEmployeeDetail/:id +func (c *BackendOrganizationController) GetEmployeeDetail() { + tid, ok := c.tenantID() + if !ok { + return + } + id, valid := c.pathUint(":id") + if !valid { + c.jsonError(400, "无效ID") + return + } + + var row models.BackendEmployee + if err := c.employeeQuery(tid).Filter("id", id).One(&row); err != nil { + c.jsonError(404, "员工不存在") + return + } + + c.jsonOK(c.employeeDTO(tid, row)) +} + +// CreateEmployee 创建员工。 +// POST /backend/{erp|oa}/createEmployee +func (c *BackendOrganizationController) CreateEmployee() { + tid, ok := c.tenantID() + if !ok { + return + } + body := c.parseJSONBody() + settings := c.loadOrgSettings(tid) + + name, _ := c.getStringValue(body, "name") + name = strings.TrimSpace(name) + if name == "" { + c.jsonError(400, "姓名不能为空") + return + } + + account, _ := c.getStringValue(body, "account") + account = strings.TrimSpace(account) + if account == "" { + if !settings.AutoGenerateCodes { + c.jsonError(400, "账号不能为空") + return + } + account = c.generateCode(settings.EmployeeCodePrefix, settings.CodeLength) + } + count, err := c.employeeQuery(tid).Filter("account", account).Count() + if err != nil { + c.jsonError(500, "校验账号失败: "+err.Error()) + return + } + if count > 0 { + c.jsonError(400, "账号已存在") + return + } + + gender, hasGender := c.getIntValue(body, "gender", "sex") + status, hasStatus := c.getIntValue(body, "account_status", "status") + password, _ := c.getStringValue(body, "password") + birthday, _ := c.getStringValue(body, "birthday") + affiliateUnit, _ := c.getStringValue(body, "affiliate_unit") + department, _ := c.getStringValue(body, "department") + position, _ := c.getStringValue(body, "position") + education, _ := c.getStringValue(body, "education") + nation, _ := c.getStringValue(body, "nation") + phone, _ := c.getStringValue(body, "phone") + wechat, _ := c.getStringValue(body, "wechat") + email, _ := c.getStringValue(body, "email") + homeAddress, _ := c.getStringValue(body, "home_address") + + if err := c.validateEmployeeOrg(tid, affiliateUnit, department); err != nil { + c.jsonError(400, err.Error()) + return + } + + tidInt := int(tid) + row := models.BackendEmployee{ + Tid: &tidInt, + Account: account, + Password: hashEmployeePassword(password), + Name: name, + Gender: 0, + Birthday: parseDatePtr(birthday), + AffiliateUnit: strPtrIfNotEmpty(affiliateUnit), + Department: strPtrIfNotEmpty(department), + Position: strPtrIfNotEmpty(position), + Education: strPtrIfNotEmpty(education), + Nation: strPtrIfNotEmpty(nation), + Phone: strPtrIfNotEmpty(phone), + Wechat: strPtrIfNotEmpty(wechat), + Email: strPtrIfNotEmpty(email), + HomeAddress: strPtrIfNotEmpty(homeAddress), + AccountStatus: 1, + } + if hasGender { + row.Gender = int8(gender) + } + if hasStatus { + row.AccountStatus = int8(status) + } + + id, err := models.Orm.Insert(&row) + if err != nil { + c.jsonError(500, "创建员工失败: "+err.Error()) + return + } + + c.jsonOK(map[string]interface{}{"id": id, "account": account}) +} + +// EditEmployee 更新员工。 +// POST /backend/{erp|oa}/editEmployee/:id +func (c *BackendOrganizationController) EditEmployee() { + tid, ok := c.tenantID() + if !ok { + return + } + id, valid := c.pathUint(":id") + if !valid { + c.jsonError(400, "无效ID") + return + } + + if !c.employeeQuery(tid).Filter("id", id).Exist() { + c.jsonError(404, "员工不存在") + return + } + + body := c.parseJSONBody() + update := orm.Params{} + + if v, has := c.getStringValue(body, "account"); has && strings.TrimSpace(v) != "" { + v = strings.TrimSpace(v) + count, err := c.employeeQuery(tid).Filter("account", v).Exclude("id", id).Count() + if err != nil { + c.jsonError(500, "校验账号失败: "+err.Error()) + return + } + if count > 0 { + c.jsonError(400, "账号已存在") + return + } + update["account"] = v + } + if v, has := c.getStringValue(body, "name"); has { + v = strings.TrimSpace(v) + if v == "" { + c.jsonError(400, "姓名不能为空") + return + } + update["name"] = v + } + if v, has := c.getIntValue(body, "gender", "sex"); has { + update["gender"] = int8(v) + } + if v, has := c.getStringValue(body, "birthday"); has { + update["birthday"] = parseDatePtr(v) + } + + affiliateUnit, hasAffiliate := c.getStringValue(body, "affiliate_unit") + department, hasDepartment := c.getStringValue(body, "department") + if hasAffiliate || hasDepartment { + if err := c.validateEmployeeOrg(tid, affiliateUnit, department); err != nil { + c.jsonError(400, err.Error()) + return + } + } + if hasAffiliate { + update["affiliate_unit"] = nullableString(affiliateUnit) + } + if hasDepartment { + update["department"] = nullableString(department) + } + + if v, has := c.getStringValue(body, "position"); has { + update["position"] = nullableString(v) + } + if v, has := c.getStringValue(body, "education"); has { + update["education"] = nullableString(v) + } + if v, has := c.getStringValue(body, "nation"); has { + update["nation"] = nullableString(v) + } + if v, has := c.getStringValue(body, "phone"); has { + update["phone"] = nullableString(v) + } + if v, has := c.getStringValue(body, "wechat"); has { + update["wechat"] = nullableString(v) + } + if v, has := c.getStringValue(body, "email"); has { + update["email"] = nullableString(v) + } + if v, has := c.getStringValue(body, "home_address"); has { + update["home_address"] = nullableString(v) + } + if v, has := c.getIntValue(body, "account_status", "status"); has { + update["account_status"] = int8(v) + } + if v, has := c.getStringValue(body, "password"); has && strings.TrimSpace(v) != "" { + update["password"] = hashEmployeePassword(v) + } + + if len(update) == 0 { + c.jsonError(400, "无更新字段") + return + } + + if _, err := c.employeeQuery(tid).Filter("id", id).Update(update); err != nil { + c.jsonError(500, "更新员工失败: "+err.Error()) + return + } + + c.jsonOK(nil) +} + +// DeleteEmployee 删除员工(软删除)。 +// DELETE /backend/{erp|oa}/deleteEmployee/:id +func (c *BackendOrganizationController) DeleteEmployee() { + tid, ok := c.tenantID() + if !ok { + return + } + id, valid := c.pathUint(":id") + if !valid { + c.jsonError(400, "无效ID") + return + } + + num, err := c.employeeQuery(tid).Filter("id", id). + Update(orm.Params{"delete_time": c.nowString(), "account_status": int8(2)}) + if err != nil { + c.jsonError(500, "删除员工失败: "+err.Error()) + return + } + if num == 0 { + c.jsonError(404, "员工不存在") + return + } + + c.jsonOK(nil) +} + +// MoveEmployeeToOrg 员工调岗:批量或单个移动到指定组织。 +// POST /backend/{erp|oa}/moveEmployeeToOrg +func (c *BackendOrganizationController) MoveEmployeeToOrg() { + tid, ok := c.tenantID() + if !ok { + return + } + body := c.parseJSONBody() + + orgID, hasOrg := c.getUint64Value(body, "org_id", "department_id") + if !hasOrg || orgID == 0 { + c.jsonError(400, "组织ID不能为空") + return + } + + ids := c.getUintSlice(body, "employee_ids", "ids") + if single, has := c.getUintValue(body, "employee_id"); has && single > 0 { + ids = append(ids, single) + } + if len(ids) == 0 { + c.jsonError(400, "请选择要调岗的员工") + return + } + + var org models.BackendOrganization + if err := c.orgQuery(tid).Filter("id", orgID).One(&org); err != nil { + c.jsonError(400, "目标组织不存在") + return + } + + update := orm.Params{"department": strconv.FormatUint(orgID, 10)} + // 部门变更时同步隶属单位:取该组织所在的顶层公司 + if companyID := c.rootCompanyID(tid, org); companyID > 0 { + update["affiliate_unit"] = strconv.FormatUint(companyID, 10) + } + if v, has := c.getStringValue(body, "position"); has { + update["position"] = nullableString(v) + } + + num, err := c.employeeQuery(tid).Filter("id__in", ids).Update(update) + if err != nil { + c.jsonError(500, "调岗失败: "+err.Error()) + return + } + if num == 0 { + c.jsonError(404, "员工不存在") + return + } + + c.jsonOK(map[string]interface{}{"affected": num}) +} + +// CheckEmployeeAccountUnique 检查员工账号唯一性。 +// GET /backend/{erp|oa}/checkEmployeeAccountUnique?account=xx&exclude_id=1 +func (c *BackendOrganizationController) CheckEmployeeAccountUnique() { + tid, ok := c.tenantID() + if !ok { + return + } + account := strings.TrimSpace(c.GetString("account")) + if account == "" { + c.jsonError(400, "员工账号不能为空") + return + } + excludeID, _ := c.GetUint64("exclude_id") + if excludeID == 0 { + excludeID, _ = c.GetUint64("employee_id") + } + + qs := c.employeeQuery(tid).Filter("account", account) + if excludeID > 0 { + qs = qs.Exclude("id", excludeID) + } + + count, err := qs.Count() + if err != nil { + c.jsonError(500, "检查账号唯一性失败: "+err.Error()) + return + } + + c.jsonOK(map[string]interface{}{"unique": count == 0, "message": uniqueMessage(count == 0, "账号")}) +} + +// --------------------------------------------------------------------------- +// 职位 +// --------------------------------------------------------------------------- + +// GetPosition 获取职位列表。 +// GET /backend/{erp|oa}/getPosition?department_id=1&keyword=xx +func (c *BackendOrganizationController) GetPosition() { + tid, ok := c.tenantID() + if !ok { + return + } + + cond := orm.NewCondition().And("tid", tid).And("delete_time__isnull", true) + if departmentID, _ := c.GetUint64("department_id"); departmentID > 0 { + cond = cond.And("department_id", departmentID) + } + if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" { + cond = cond.AndCond(orm.NewCondition(). + Or("position_name__icontains", keyword). + Or("position_code__icontains", keyword)) + } + if raw := strings.TrimSpace(c.GetString("status")); raw != "" { + if status, err := strconv.Atoi(raw); err == nil { + cond = cond.And("status", int8(status)) + } + } + + var rows []models.BackendPosition + _, err := models.Orm.QueryTable(new(models.BackendPosition)). + SetCond(cond).OrderBy("sort", "id").All(&rows) + if err != nil { + c.jsonError(500, "查询职位失败: "+err.Error()) + return + } + + list := make([]positionDTO, 0, len(rows)) + for _, row := range rows { + list = append(list, c.positionDTO(tid, row)) + } + + c.jsonOK(list) +} + +// GetPositionDetail 获取职位详情。 +// GET /backend/{erp|oa}/getPositionDetail/:id +func (c *BackendOrganizationController) GetPositionDetail() { + tid, ok := c.tenantID() + if !ok { + return + } + id, valid := c.pathUint64(":id") + if !valid { + c.jsonError(400, "无效ID") + return + } + + var row models.BackendPosition + if err := c.positionQuery(tid).Filter("id", id).One(&row); err != nil { + c.jsonError(404, "职位不存在") + return + } + + c.jsonOK(c.positionDTO(tid, row)) +} + +// CreatePosition 创建职位。 +// POST /backend/{erp|oa}/createPosition +func (c *BackendOrganizationController) CreatePosition() { + tid, ok := c.tenantID() + if !ok { + return + } + body := c.parseJSONBody() + settings := c.loadOrgSettings(tid) + + positionName, _ := c.getStringValue(body, "position_name", "name") + positionName = strings.TrimSpace(positionName) + if positionName == "" { + c.jsonError(400, "职位名称不能为空") + return + } + + departmentID, _ := c.getUint64Value(body, "department_id") + if departmentID > 0 && !c.orgExists(tid, departmentID) { + c.jsonError(400, "所属部门不存在") + return + } + + positionCode, _ := c.getStringValue(body, "position_code", "code") + positionCode = strings.TrimSpace(positionCode) + if positionCode == "" { + if !settings.AutoGenerateCodes { + c.jsonError(400, "职位编码不能为空") + return + } + positionCode = c.generateCode(settings.PositionCodePrefix, settings.CodeLength) + } + if !settings.AllowDuplicateCode { + count, err := c.positionQuery(tid).Filter("position_code", positionCode).Count() + if err != nil { + c.jsonError(500, "校验职位编码失败: "+err.Error()) + return + } + if count > 0 { + c.jsonError(400, "职位编码已存在") + return + } + } + + positionType, _ := c.getIntValue(body, "position_type") + status, hasStatus := c.getIntValue(body, "status") + sortVal, _ := c.getUintValue(body, "sort") + remark, _ := c.getStringValue(body, "remark") + + row := models.BackendPosition{ + Tid: tid, + DepartmentID: departmentID, + PositionCode: positionCode, + PositionName: positionName, + PositionType: int8(positionType), + Status: 1, + Sort: sortVal, + Remark: strPtrIfNotEmpty(remark), + } + if hasStatus { + row.Status = int8(status) + } + + id, err := models.Orm.Insert(&row) + if err != nil { + c.jsonError(500, "创建职位失败: "+err.Error()) + return + } + + c.jsonOK(map[string]interface{}{"id": id, "position_code": positionCode}) +} + +// EditPosition 更新职位。 +// POST /backend/{erp|oa}/editPosition/:id +func (c *BackendOrganizationController) EditPosition() { + tid, ok := c.tenantID() + if !ok { + return + } + id, valid := c.pathUint64(":id") + if !valid { + c.jsonError(400, "无效ID") + return + } + if !c.positionQuery(tid).Filter("id", id).Exist() { + c.jsonError(404, "职位不存在") + return + } + + body := c.parseJSONBody() + settings := c.loadOrgSettings(tid) + update := orm.Params{} + + if v, has := c.getUint64Value(body, "department_id"); has { + if v > 0 && !c.orgExists(tid, v) { + c.jsonError(400, "所属部门不存在") + return + } + update["department_id"] = v + } + if v, has := c.getStringValue(body, "position_code", "code"); has { + v = strings.TrimSpace(v) + if v == "" { + c.jsonError(400, "职位编码不能为空") + return + } + if !settings.AllowDuplicateCode { + count, err := c.positionQuery(tid).Filter("position_code", v).Exclude("id", id).Count() + if err != nil { + c.jsonError(500, "校验职位编码失败: "+err.Error()) + return + } + if count > 0 { + c.jsonError(400, "职位编码已存在") + return + } + } + update["position_code"] = v + } + if v, has := c.getStringValue(body, "position_name", "name"); has { + v = strings.TrimSpace(v) + if v == "" { + c.jsonError(400, "职位名称不能为空") + return + } + update["position_name"] = v + } + if v, has := c.getIntValue(body, "position_type"); has { + update["position_type"] = int8(v) + } + if v, has := c.getIntValue(body, "status"); has { + update["status"] = int8(v) + } + if v, has := c.getUintValue(body, "sort"); has { + update["sort"] = v + } + if v, has := c.getStringValue(body, "remark"); has { + update["remark"] = nullableString(v) + } + + if len(update) == 0 { + c.jsonError(400, "无更新字段") + return + } + + if _, err := c.positionQuery(tid).Filter("id", id).Update(update); err != nil { + c.jsonError(500, "更新职位失败: "+err.Error()) + return + } + + c.jsonOK(nil) +} + +// DeletePosition 删除职位(软删除)。 +// DELETE /backend/{erp|oa}/deletePosition/:id +func (c *BackendOrganizationController) DeletePosition() { + tid, ok := c.tenantID() + if !ok { + return + } + id, valid := c.pathUint64(":id") + if !valid { + c.jsonError(400, "无效ID") + return + } + + num, err := c.positionQuery(tid).Filter("id", id). + Update(orm.Params{"delete_time": c.nowString(), "status": int8(0)}) + if err != nil { + c.jsonError(500, "删除职位失败: "+err.Error()) + return + } + if num == 0 { + c.jsonError(404, "职位不存在") + return + } + + c.jsonOK(nil) +} + +// CheckPositionCodeUnique 检查职位编码唯一性。 +// GET /backend/{erp|oa}/checkPositionCodeUnique?position_code=xx&exclude_id=1 +func (c *BackendOrganizationController) CheckPositionCodeUnique() { + tid, ok := c.tenantID() + if !ok { + return + } + code := strings.TrimSpace(c.GetString("position_code")) + if code == "" { + c.jsonError(400, "职位编码不能为空") + return + } + excludeID, _ := c.GetUint64("exclude_id") + + qs := c.positionQuery(tid).Filter("position_code", code) + if excludeID > 0 { + qs = qs.Exclude("id", excludeID) + } + + count, err := qs.Count() + if err != nil { + c.jsonError(500, "检查编码唯一性失败: "+err.Error()) + return + } + + c.jsonOK(map[string]interface{}{"unique": count == 0, "message": uniqueMessage(count == 0, "编码")}) +} diff --git a/go/controllers/backend_organization_dto.go b/go/controllers/backend_organization_dto.go new file mode 100644 index 0000000..e6dc927 --- /dev/null +++ b/go/controllers/backend_organization_dto.go @@ -0,0 +1,479 @@ +package controllers + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "server/models" +) + +// 本文件包含组织架构模块的 DTO 组装、树结构与层级校验,以及请求参数解析工具。 + +// --------------------------------------------------------------------------- +// DTO 组装 +// --------------------------------------------------------------------------- + +// organizationDTOList 批量组装组织 DTO。一次性把父组织名、负责人名、员工数查出来, +// 避免逐行查询导致的 N+1 问题。 +func (c *BackendOrganizationController) organizationDTOList(tid uint64, rows []models.BackendOrganization) []organizationDTO { + list := make([]organizationDTO, 0, len(rows)) + if len(rows) == 0 { + return list + } + + nameByID := c.orgNameMap(tid) + leaderNames := c.employeeNameMap(tid) + counts := c.employeeCountByDepartment(tid) + + for _, row := range rows { + list = append(list, c.assembleOrganizationDTO(row, nameByID, leaderNames, counts)) + } + return list +} + +func (c *BackendOrganizationController) organizationDTO(tid uint64, row models.BackendOrganization) organizationDTO { + return c.assembleOrganizationDTO(row, + c.orgNameMap(tid), c.employeeNameMap(tid), c.employeeCountByDepartment(tid)) +} + +func (c *BackendOrganizationController) assembleOrganizationDTO( + row models.BackendOrganization, + nameByID map[uint64]string, + leaderNames map[uint64]string, + counts map[string]int64, +) organizationDTO { + leaderID := uint64(0) + if row.LeaderID != nil { + leaderID = *row.LeaderID + } + + return organizationDTO{ + ID: row.ID, + Tid: row.Tid, + TenantID: row.Tid, + OrgName: row.OrgName, + OrgCode: row.OrgCode, + ParentID: row.ParentID, + ParentName: nameByID[row.ParentID], + LeaderID: leaderID, + LeaderName: leaderNames[leaderID], + IsCompany: row.IsCompany, + Sort: row.Sort, + Status: row.Status, + Remark: derefString(row.Remark), + EmployeeCount: counts[strconv.FormatUint(row.ID, 10)], + CreateTime: formatDateTime(&row.CreateTime), + UpdateTime: formatDateTime(&row.UpdateTime), + } +} + +func (c *BackendOrganizationController) employeeDTOList(tid uint64, rows []models.BackendEmployee) []employeeDTO { + list := make([]employeeDTO, 0, len(rows)) + if len(rows) == 0 { + return list + } + + nameByID := c.orgNameMap(tid) + for _, row := range rows { + list = append(list, c.assembleEmployeeDTO(row, nameByID)) + } + return list +} + +func (c *BackendOrganizationController) employeeDTO(tid uint64, row models.BackendEmployee) employeeDTO { + return c.assembleEmployeeDTO(row, c.orgNameMap(tid)) +} + +func (c *BackendOrganizationController) assembleEmployeeDTO( + row models.BackendEmployee, + nameByID map[uint64]string, +) employeeDTO { + tid := 0 + if row.Tid != nil { + tid = *row.Tid + } + + birthday := "" + if row.Birthday != nil { + birthday = row.Birthday.Format("2006-01-02") + } + + affiliateUnit := derefString(row.AffiliateUnit) + department := derefString(row.Department) + + return employeeDTO{ + ID: row.ID, + Tid: tid, + TenantID: tid, + Account: row.Account, + Name: row.Name, + Gender: row.Gender, + Sex: row.Gender, + Birthday: birthday, + AffiliateUnit: affiliateUnit, + AffiliateUnitName: orgNameByIDString(nameByID, affiliateUnit), + Department: department, + DepartmentName: orgNameByIDString(nameByID, department), + Position: derefString(row.Position), + Education: derefString(row.Education), + Nation: derefString(row.Nation), + Phone: derefString(row.Phone), + Wechat: derefString(row.Wechat), + Email: derefString(row.Email), + HomeAddress: derefString(row.HomeAddress), + AccountStatus: row.AccountStatus, + Status: row.AccountStatus, + CreateTime: formatDateTime(&row.CreateTime), + } +} + +func (c *BackendOrganizationController) positionDTO(tid uint64, row models.BackendPosition) positionDTO { + nameByID := c.orgNameMap(tid) + return positionDTO{ + ID: row.ID, + Tid: row.Tid, + TenantID: row.Tid, + DepartmentID: row.DepartmentID, + DepartmentName: nameByID[row.DepartmentID], + PositionCode: row.PositionCode, + PositionName: row.PositionName, + PositionType: row.PositionType, + Status: row.Status, + Sort: row.Sort, + Remark: derefString(row.Remark), + CreateTime: formatDateTime(&row.CreateTime), + } +} + +// --------------------------------------------------------------------------- +// 组织关系查询 +// --------------------------------------------------------------------------- + +func (c *BackendOrganizationController) orgNameMap(tid uint64) map[uint64]string { + result := map[uint64]string{} + var rows []models.BackendOrganization + if _, err := c.orgQuery(tid).All(&rows, "ID", "OrgName"); err != nil { + return result + } + for _, row := range rows { + result[row.ID] = row.OrgName + } + return result +} + +func (c *BackendOrganizationController) employeeNameMap(tid uint64) map[uint64]string { + result := map[uint64]string{} + var rows []models.BackendEmployee + if _, err := c.employeeQuery(tid).All(&rows, "ID", "Name"); err != nil { + return result + } + for _, row := range rows { + result[uint64(row.ID)] = row.Name + } + return result +} + +// employeeCountByDepartment 统计各部门(department 存的是组织ID字符串)的员工数。 +func (c *BackendOrganizationController) employeeCountByDepartment(tid uint64) map[string]int64 { + result := map[string]int64{} + var rows []models.BackendEmployee + if _, err := c.employeeQuery(tid).All(&rows, "Department"); err != nil { + return result + } + for _, row := range rows { + key := strings.TrimSpace(derefString(row.Department)) + if key == "" { + continue + } + result[key]++ + } + return result +} + +func (c *BackendOrganizationController) orgExists(tid, id uint64) bool { + if id == 0 { + return false + } + return c.orgQuery(tid).Filter("id", id).Exist() +} + +// parentMap 返回 组织ID -> 上级组织ID 的映射,用于层级与环路判断。 +func (c *BackendOrganizationController) parentMap(tid uint64) map[uint64]uint64 { + result := map[uint64]uint64{} + var rows []models.BackendOrganization + if _, err := c.orgQuery(tid).All(&rows, "ID", "ParentID"); err != nil { + return result + } + for _, row := range rows { + result[row.ID] = row.ParentID + } + return result +} + +// orgDepth 计算组织所在层级,顶级为 1。 +func (c *BackendOrganizationController) orgDepth(tid, id uint64) (int, error) { + parents := c.parentMap(tid) + depth := 0 + current := id + for current > 0 { + depth++ + if depth > 64 { + return depth, errors.New("组织层级数据异常(可能存在环路)") + } + next, ok := parents[current] + if !ok { + break + } + current = next + } + return depth, nil +} + +// subtreeHeight 计算以 id 为根的子树高度(只有自身时为 1)。 +func (c *BackendOrganizationController) subtreeHeight(tid, id uint64) int { + childrenOf := map[uint64][]uint64{} + var rows []models.BackendOrganization + if _, err := c.orgQuery(tid).All(&rows, "ID", "ParentID"); err != nil { + return 1 + } + for _, row := range rows { + childrenOf[row.ParentID] = append(childrenOf[row.ParentID], row.ID) + } + return subtreeHeightFrom(childrenOf, id, 0) +} + +func subtreeHeightFrom(childrenOf map[uint64][]uint64, id uint64, depth int) int { + if depth > 64 { + return depth + } + height := 1 + for _, child := range childrenOf[id] { + if h := subtreeHeightFrom(childrenOf, child, depth+1) + 1; h > height { + height = h + } + } + return height +} + +// collectOrgIDs 返回 rootID 及其所有下级组织的ID。 +func (c *BackendOrganizationController) collectOrgIDs(tid, rootID uint64) []uint64 { + childrenOf := map[uint64][]uint64{} + var rows []models.BackendOrganization + if _, err := c.orgQuery(tid).All(&rows, "ID", "ParentID"); err != nil { + return []uint64{rootID} + } + for _, row := range rows { + childrenOf[row.ParentID] = append(childrenOf[row.ParentID], row.ID) + } + + result := []uint64{rootID} + queue := []uint64{rootID} + visited := map[uint64]bool{rootID: true} + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + for _, child := range childrenOf[current] { + if visited[child] { + continue + } + visited[child] = true + result = append(result, child) + queue = append(queue, child) + } + } + return result +} + +// rootCompanyID 沿上级链向上找到所属的顶层公司ID。 +func (c *BackendOrganizationController) rootCompanyID(tid uint64, org models.BackendOrganization) uint64 { + if org.IsCompany == 1 { + return org.ID + } + parents := c.parentMap(tid) + current := org.ParentID + for i := 0; i < 64 && current > 0; i++ { + var row models.BackendOrganization + if err := c.orgQuery(tid).Filter("id", current).One(&row); err != nil { + return 0 + } + if row.IsCompany == 1 { + return row.ID + } + next, ok := parents[current] + if !ok { + return 0 + } + current = next + } + return 0 +} + +// validateParentChange 校验把 orgID 挂到 newParentID 下是否合法: +// 不能挂到自己或自己的后代(形成环路),且移动后总层级不超过设置上限。 +func (c *BackendOrganizationController) validateParentChange(tid, orgID, newParentID uint64, settings orgSettings) error { + if newParentID == 0 { + return nil + } + if newParentID == orgID { + return errors.New("上级组织不能是自己") + } + if !c.orgExists(tid, newParentID) { + return errors.New("上级组织不存在") + } + + for _, id := range c.collectOrgIDs(tid, orgID) { + if id == newParentID { + return errors.New("不能将组织移动到自己的下级组织中") + } + } + + parentDepth, err := c.orgDepth(tid, newParentID) + if err != nil { + return err + } + if settings.MaxOrgLevels > 0 && parentDepth+c.subtreeHeight(tid, orgID) > settings.MaxOrgLevels { + return fmt.Errorf("移动后组织层级将超过上限 %d 级", settings.MaxOrgLevels) + } + if settings.MaxOrgChildren > 0 { + count, err := c.orgQuery(tid).Filter("parent_id", newParentID). + Exclude("id", orgID).Exclude("status", 0).Count() + if err == nil && int(count) >= settings.MaxOrgChildren { + return fmt.Errorf("同一上级下最多 %d 个子组织", settings.MaxOrgChildren) + } + } + return nil +} + +// validateEmployeeOrg 校验员工的隶属单位与部门必须是当前租户下已存在的组织。 +func (c *BackendOrganizationController) validateEmployeeOrg(tid uint64, affiliateUnit, department string) error { + for label, raw := range map[string]string{"隶属单位": affiliateUnit, "部门": department} { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + id, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return fmt.Errorf("%s格式不正确", label) + } + if !c.orgExists(tid, id) { + return fmt.Errorf("%s不存在", label) + } + } + return nil +} + +// buildOrganizationTree 把扁平的组织 DTO 列表组装成树。 +// 上级不在列表中的节点(如上级被禁用)作为根节点返回,避免数据丢失。 +func buildOrganizationTree(list []organizationDTO) []map[string]interface{} { + nodeMap := make(map[uint64]map[string]interface{}, len(list)) + order := make([]uint64, 0, len(list)) + + for _, item := range list { + raw, _ := json.Marshal(item) + node := map[string]interface{}{} + _ = json.Unmarshal(raw, &node) + node["children"] = make([]map[string]interface{}, 0) + nodeMap[item.ID] = node + order = append(order, item.ID) + } + + tree := make([]map[string]interface{}, 0) + for _, id := range order { + node := nodeMap[id] + parentID := uint64(0) + if v, ok := node["parent_id"].(float64); ok { + parentID = uint64(v) + } + if parent, exists := nodeMap[parentID]; parentID > 0 && exists { + parent["children"] = append(parent["children"].([]map[string]interface{}), node) + continue + } + tree = append(tree, node) + } + return tree +} + +// treeDepth 计算树的最大深度。 +func treeDepth(childrenOf map[uint64][]uint64, rootID uint64, depth int) int { + if depth > 64 { + return depth + } + maxDepth := depth + for _, child := range childrenOf[rootID] { + if d := treeDepth(childrenOf, child, depth+1); d > maxDepth { + maxDepth = d + } + } + return maxDepth +} + +func orgNameByIDString(nameByID map[uint64]string, raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + id, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return "" + } + return nameByID[id] +} + +func uniqueMessage(unique bool, subject string) string { + if unique { + return subject + "可用" + } + return subject + "已存在" +} + +func formatDateTime(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} + +func clampInt(v, min, max int) int { + if v < min { + return min + } + if v > max { + return max + } + return v +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +// generateCode 生成形如 ORG20260827193012 的编码,长度不足时补时间戳末尾数字。 +func (c *BackendOrganizationController) generateCode(prefix string, length int) string { + prefix = strings.TrimSpace(prefix) + stamp := time.Now().Format("20060102150405") + code := prefix + stamp + if length > 0 && len(code) > length && len(prefix) < length { + keep := length - len(prefix) + code = prefix + stamp[len(stamp)-keep:] + } + return code +} + +// hashEmployeePassword 适配 password varchar(64),使用 sha256 hex;空密码返回空串。 +func hashEmployeePassword(plain string) string { + plain = strings.TrimSpace(plain) + if plain == "" { + return "" + } + sum := sha256.Sum256([]byte(plain)) + return hex.EncodeToString(sum[:]) +} diff --git a/go/controllers/backend_organization_params.go b/go/controllers/backend_organization_params.go new file mode 100644 index 0000000..bd6b544 --- /dev/null +++ b/go/controllers/backend_organization_params.go @@ -0,0 +1,289 @@ +package controllers + +import ( + "encoding/json" + "strconv" + "strings" + "time" +) + +// 本文件包含组织架构模块的请求参数解析工具。 +// 前端存在 JSON、form-urlencoded 与 multipart/form-data 三种提交方式, +// 因此每个取值函数都先读 JSON body,再回退到表单参数。 + +func (c *BackendOrganizationController) parseJSONBody() map[string]interface{} { + body := map[string]interface{}{} + contentType := strings.ToLower(c.Ctx.Input.Header("Content-Type")) + if !strings.Contains(contentType, "json") { + return body + } + if len(c.Ctx.Input.RequestBody) == 0 { + return body + } + _ = json.Unmarshal(c.Ctx.Input.RequestBody, &body) + return body +} + +// ensureFormParsed 在读取表单参数前确保请求体已被解析。 +func (c *BackendOrganizationController) ensureFormParsed() { + if c.Ctx.Request.Form == nil && c.Ctx.Request.PostForm == nil && c.Ctx.Request.MultipartForm == nil { + _ = c.Ctx.Request.ParseMultipartForm(32 << 20) + } +} + +func (c *BackendOrganizationController) getStringValue(body map[string]interface{}, keys ...string) (string, bool) { + for _, key := range keys { + if v, ok := body[key]; ok { + switch val := v.(type) { + case string: + return val, true + case float64: + return strconv.FormatFloat(val, 'f', -1, 64), true + case bool: + return strconv.FormatBool(val), true + case nil: + return "", true + } + } + c.ensureFormParsed() + if val := c.GetString(key); val != "" { + return val, true + } + } + return "", false +} + +func (c *BackendOrganizationController) getIntValue(body map[string]interface{}, keys ...string) (int, bool) { + for _, key := range keys { + if v, ok := body[key]; ok { + switch val := v.(type) { + case float64: + return int(val), true + case int: + return val, true + case bool: + return boolInt(val), true + case string: + trimmed := strings.TrimSpace(val) + if trimmed == "" { + return 0, true + } + parsed, err := strconv.Atoi(trimmed) + return parsed, err == nil + } + } + c.ensureFormParsed() + if val := c.GetString(key); val != "" { + parsed, err := strconv.Atoi(strings.TrimSpace(val)) + return parsed, err == nil + } + } + return 0, false +} + +func (c *BackendOrganizationController) getUintValue(body map[string]interface{}, keys ...string) (uint, bool) { + v, ok := c.getIntValue(body, keys...) + if !ok || v < 0 { + return 0, ok && v >= 0 + } + return uint(v), true +} + +func (c *BackendOrganizationController) getUint64Value(body map[string]interface{}, keys ...string) (uint64, bool) { + for _, key := range keys { + if v, ok := body[key]; ok { + switch val := v.(type) { + case float64: + if val < 0 { + return 0, false + } + return uint64(val), true + case int: + if val < 0 { + return 0, false + } + return uint64(val), true + case nil: + return 0, true + case string: + trimmed := strings.TrimSpace(val) + if trimmed == "" { + return 0, true + } + parsed, err := strconv.ParseUint(trimmed, 10, 64) + return parsed, err == nil + } + } + c.ensureFormParsed() + if val := c.GetString(key); val != "" { + parsed, err := strconv.ParseUint(strings.TrimSpace(val), 10, 64) + return parsed, err == nil + } + } + return 0, false +} + +func (c *BackendOrganizationController) getBoolValue(body map[string]interface{}, keys ...string) (bool, bool) { + for _, key := range keys { + if v, ok := body[key]; ok { + switch val := v.(type) { + case bool: + return val, true + case float64: + return val != 0, true + case string: + trimmed := strings.ToLower(strings.TrimSpace(val)) + switch trimmed { + case "1", "true", "yes", "on": + return true, true + case "0", "false", "no", "off", "": + return false, true + } + } + } + c.ensureFormParsed() + if val := c.GetString(key); val != "" { + switch strings.ToLower(strings.TrimSpace(val)) { + case "1", "true", "yes", "on": + return true, true + case "0", "false", "no", "off": + return false, true + } + } + } + return false, false +} + +// getUint64Slice 解析 ID 数组,兼容 JSON 数组与逗号分隔字符串。 +func (c *BackendOrganizationController) getUint64Slice(body map[string]interface{}, keys ...string) []uint64 { + result := make([]uint64, 0) + seen := map[uint64]bool{} + + appendID := func(id uint64) { + if id == 0 || seen[id] { + return + } + seen[id] = true + result = append(result, id) + } + + for _, key := range keys { + if v, ok := body[key]; ok { + switch val := v.(type) { + case []interface{}: + for _, item := range val { + switch num := item.(type) { + case float64: + appendID(uint64(num)) + case string: + if parsed, err := strconv.ParseUint(strings.TrimSpace(num), 10, 64); err == nil { + appendID(parsed) + } + } + } + case string: + for _, part := range strings.Split(val, ",") { + if parsed, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64); err == nil { + appendID(parsed) + } + } + case float64: + appendID(uint64(val)) + } + } + if len(result) > 0 { + return result + } + c.ensureFormParsed() + if raw := c.GetString(key); raw != "" { + for _, part := range strings.Split(raw, ",") { + if parsed, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64); err == nil { + appendID(parsed) + } + } + } + if len(result) > 0 { + return result + } + } + return result +} + +func (c *BackendOrganizationController) getUintSlice(body map[string]interface{}, keys ...string) []uint { + ids := c.getUint64Slice(body, keys...) + result := make([]uint, 0, len(ids)) + for _, id := range ids { + result = append(result, uint(id)) + } + return result +} + +func (c *BackendOrganizationController) pathUint(name string) (uint, bool) { + id, err := strconv.ParseUint(c.Ctx.Input.Param(name), 10, 64) + return uint(id), err == nil && id > 0 +} + +func (c *BackendOrganizationController) pathUint64(name string) (uint64, bool) { + id, err := strconv.ParseUint(c.Ctx.Input.Param(name), 10, 64) + return id, err == nil && id > 0 +} + +func (c *BackendOrganizationController) nowString() string { + return time.Now().Format("2006-01-02 15:04:05") +} + +// --------------------------------------------------------------------------- +// 与 ORM 交互的通用小工具 +// --------------------------------------------------------------------------- + +func strPtrIfNotEmpty(v string) *string { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + return &v +} + +// nullableString 空字符串写入 NULL,便于统一区分“未填写”与“空值”。 +func nullableString(v string) interface{} { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + return v +} + +func nullableUint64(v uint64) interface{} { + if v == 0 { + return nil + } + return v +} + +func derefString(v *string) string { + if v == nil { + return "" + } + return *v +} + +func boolInt(v bool) int { + if v { + return 1 + } + return 0 +} + +func parseDatePtr(v string) *time.Time { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + if t, err := time.Parse("2006-01-02", v); err == nil { + return &t + } + if t, err := time.Parse("2006-01-02 15:04:05", v); err == nil { + return &t + } + return nil +} diff --git a/go/controllers/backend_organization_support.go b/go/controllers/backend_organization_support.go new file mode 100644 index 0000000..2a9af94 --- /dev/null +++ b/go/controllers/backend_organization_support.go @@ -0,0 +1,405 @@ +package controllers + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + + "github.com/beego/beego/v2/client/orm" +) + +// 本文件承载组织架构模块的设置读写、CSV 导入导出,以及 DTO 组装与通用工具函数。 + +// --------------------------------------------------------------------------- +// 组织架构设置(按租户存放在 yz_backend_normal_setting 中) +// --------------------------------------------------------------------------- + +// GetOrgSettings 获取当前租户的组织架构设置。 +// GET /backend/{erp|oa}/getOrgSettings +func (c *BackendOrganizationController) GetOrgSettings() { + tid, ok := c.tenantID() + if !ok { + return + } + c.jsonOK(c.loadOrgSettings(tid)) +} + +// SaveOrgSettings 保存当前租户的组织架构设置。 +// POST /backend/{erp|oa}/saveOrgSettings +func (c *BackendOrganizationController) SaveOrgSettings() { + tid, ok := c.tenantID() + if !ok { + return + } + body := c.parseJSONBody() + settings := c.loadOrgSettings(tid) + + if v, has := c.getStringValue(body, "org_code_prefix", "code_prefix"); has { + settings.OrgCodePrefix = strings.TrimSpace(v) + } + if v, has := c.getStringValue(body, "employee_code_prefix"); has { + settings.EmployeeCodePrefix = strings.TrimSpace(v) + } + if v, has := c.getStringValue(body, "position_code_prefix"); has { + settings.PositionCodePrefix = strings.TrimSpace(v) + } + if v, has := c.getBoolValue(body, "auto_generate_codes", "auto_generate_code"); has { + settings.AutoGenerateCodes = v + } + if v, has := c.getIntValue(body, "code_length"); has { + settings.CodeLength = clampInt(v, 4, 32) + } + if v, has := c.getIntValue(body, "default_org_type"); has { + settings.DefaultOrgType = v + } + if v, has := c.getIntValue(body, "default_sort"); has { + settings.DefaultSort = maxInt(v, 0) + } + if v, has := c.getIntValue(body, "default_status"); has { + settings.DefaultStatus = v + } + if v, has := c.getIntValue(body, "max_org_levels", "max_level"); has { + settings.MaxOrgLevels = clampInt(v, 1, 32) + } + if v, has := c.getIntValue(body, "max_org_children"); has { + settings.MaxOrgChildren = clampInt(v, 1, 1000) + } + if v, has := c.getBoolValue(body, "allow_duplicate_codes"); has { + settings.AllowDuplicateCode = v + } + if v, has := c.getBoolValue(body, "batch_operations"); has { + settings.BatchOperations = v + } + if v, has := c.getBoolValue(body, "export_enabled"); has { + settings.ExportEnabled = v + } + if v, has := c.getBoolValue(body, "import_enabled"); has { + settings.ImportEnabled = v + } + + if err := c.persistOrgSettings(tid, settings); err != nil { + c.jsonError(500, "保存组织设置失败: "+err.Error()) + return + } + + c.jsonOK(settings) +} + +func (c *BackendOrganizationController) orgSettingsCode(tid uint64) string { + return fmt.Sprintf("%s_%d", orgSettingsCodePrefix, tid) +} + +// loadOrgSettings 读取租户设置;无记录或解析失败时回退到默认值,保证接口始终可用。 +func (c *BackendOrganizationController) loadOrgSettings(tid uint64) orgSettings { + settings := defaultOrgSettings() + + var row models.BackendNormalSetting + err := models.Orm.QueryTable(new(models.BackendNormalSetting)). + Filter("code", c.orgSettingsCode(tid)). + Filter("delete_time__isnull", true). + One(&row) + if err != nil || strings.TrimSpace(row.Value) == "" { + return settings + } + if err := json.Unmarshal([]byte(row.Value), &settings); err != nil { + return defaultOrgSettings() + } + if settings.CodeLength <= 0 { + settings.CodeLength = 8 + } + return settings +} + +func (c *BackendOrganizationController) persistOrgSettings(tid uint64, settings orgSettings) error { + raw, err := json.Marshal(settings) + if err != nil { + return err + } + code := c.orgSettingsCode(tid) + + var row models.BackendNormalSetting + err = models.Orm.QueryTable(new(models.BackendNormalSetting)). + Filter("code", code). + Filter("delete_time__isnull", true). + One(&row) + if err == nil { + now := time.Now() + row.Value = string(raw) + row.UpdateTime = &now + _, err = models.Orm.Update(&row, "value", "update_time") + return err + } + + row = models.BackendNormalSetting{ + Name: "组织架构设置", + Code: code, + Value: string(raw), + Remark: fmt.Sprintf("租户 %d 的组织架构设置", tid), + } + _, err = models.Orm.Insert(&row) + return err +} + +// --------------------------------------------------------------------------- +// 导入 / 导出(CSV,带 UTF-8 BOM,Excel 可直接打开) +// --------------------------------------------------------------------------- + +var organizationExportHeader = []string{ + "组织编码", "组织名称", "上级组织编码", "是否公司(1是0否)", "排序", "状态(1启用0禁用)", "备注", +} + +// ExportOrganization 导出当前租户组织架构为 CSV。 +// GET /backend/{erp|oa}/exportOrganization +func (c *BackendOrganizationController) ExportOrganization() { + tid, ok := c.tenantID() + if !ok { + return + } + if !c.loadOrgSettings(tid).ExportEnabled { + c.jsonError(400, "导出功能已关闭") + return + } + + var rows []models.BackendOrganization + if _, err := c.orgQuery(tid).OrderBy("sort", "id").All(&rows); err != nil { + c.jsonError(500, "导出组织架构失败: "+err.Error()) + return + } + + codeByID := map[uint64]string{} + for _, row := range rows { + codeByID[row.ID] = row.OrgCode + } + + c.Ctx.Output.Header("Content-Type", "text/csv; charset=utf-8") + c.Ctx.Output.Header("Content-Disposition", + fmt.Sprintf("attachment; filename=organization_%s.csv", time.Now().Format("20060102150405"))) + + // UTF-8 BOM,避免 Excel 打开中文乱码 + _, _ = c.Ctx.ResponseWriter.Write([]byte{0xEF, 0xBB, 0xBF}) + writer := csv.NewWriter(c.Ctx.ResponseWriter) + _ = writer.Write(organizationExportHeader) + for _, row := range rows { + _ = writer.Write([]string{ + row.OrgCode, + row.OrgName, + codeByID[row.ParentID], + strconv.Itoa(row.IsCompany), + strconv.FormatUint(uint64(row.Sort), 10), + strconv.Itoa(int(row.Status)), + derefString(row.Remark), + }) + } + writer.Flush() +} + +// ImportOrganization 从 CSV 导入组织架构。 +// 已存在的组织编码执行更新,不存在的新增;上级组织通过编码关联, +// 上级关系在所有行入库后统一回填,因此 CSV 行序不影响结果。 +// POST /backend/{erp|oa}/importOrganization (multipart/form-data, field=file) +func (c *BackendOrganizationController) ImportOrganization() { + tid, ok := c.tenantID() + if !ok { + return + } + if !c.loadOrgSettings(tid).ImportEnabled { + c.jsonError(400, "导入功能已关闭") + return + } + + file, _, err := c.GetFile("file") + if err != nil { + c.jsonError(400, "请上传 CSV 文件") + return + } + defer file.Close() + + reader := csv.NewReader(newBOMTrimReader(file)) + reader.FieldsPerRecord = -1 + records, err := reader.ReadAll() + if err != nil { + c.jsonError(400, "解析 CSV 失败: "+err.Error()) + return + } + if len(records) <= 1 { + c.jsonError(400, "CSV 中没有可导入的数据") + return + } + + idByCode := map[string]uint64{} + var existing []models.BackendOrganization + if _, err := c.orgQuery(tid).All(&existing); err != nil { + c.jsonError(500, "读取已有组织失败: "+err.Error()) + return + } + for _, row := range existing { + idByCode[row.OrgCode] = row.ID + } + + type pendingParent struct { + code string + parentCode string + } + + created, updated := 0, 0 + failures := make([]string, 0) + pending := make([]pendingParent, 0, len(records)) + + for i, record := range records[1:] { + lineNo := i + 2 + if len(record) < 2 { + failures = append(failures, fmt.Sprintf("第 %d 行:列数不足", lineNo)) + continue + } + orgCode := strings.TrimSpace(record[0]) + orgName := strings.TrimSpace(record[1]) + if orgCode == "" || orgName == "" { + failures = append(failures, fmt.Sprintf("第 %d 行:组织编码与名称不能为空", lineNo)) + continue + } + + parentCode := csvField(record, 2) + isCompany := csvInt(record, 3, 0) + sortVal := csvInt(record, 4, 0) + status := csvInt(record, 5, 1) + remark := csvField(record, 6) + + if id, exists := idByCode[orgCode]; exists { + update := orm.Params{ + "org_name": orgName, + "is_company": isCompany, + "sort": uint(maxInt(sortVal, 0)), + "status": int8(status), + "remark": nullableString(remark), + } + if _, err := c.orgQuery(tid).Filter("id", id).Update(update); err != nil { + failures = append(failures, fmt.Sprintf("第 %d 行:更新失败 %s", lineNo, err.Error())) + continue + } + updated++ + } else { + row := models.BackendOrganization{ + Tid: tid, + OrgName: orgName, + OrgCode: orgCode, + IsCompany: isCompany, + Sort: uint(maxInt(sortVal, 0)), + Status: int8(status), + Remark: strPtrIfNotEmpty(remark), + } + id, err := models.Orm.Insert(&row) + if err != nil { + failures = append(failures, fmt.Sprintf("第 %d 行:创建失败 %s", lineNo, err.Error())) + continue + } + idByCode[orgCode] = uint64(id) + created++ + } + + pending = append(pending, pendingParent{code: orgCode, parentCode: parentCode}) + } + + for _, item := range pending { + selfID := idByCode[item.code] + if selfID == 0 { + continue + } + parentID := uint64(0) + if item.parentCode != "" { + parentID = idByCode[item.parentCode] + if parentID == 0 { + failures = append(failures, fmt.Sprintf("组织 %s:上级编码 %s 不存在", item.code, item.parentCode)) + continue + } + if parentID == selfID { + failures = append(failures, fmt.Sprintf("组织 %s:上级不能是自己", item.code)) + continue + } + } + _, _ = c.orgQuery(tid).Filter("id", selfID). + Update(orm.Params{"parent_id": parentID, "is_company": boolInt(parentID == 0)}) + } + + c.jsonOK(map[string]interface{}{ + "created": created, + "updated": updated, + "failed": len(failures), + "failures": failures, + }) +} + +// GetImportTemplate 下载导入模板(仅表头 + 一行示例)。 +// GET /backend/{erp|oa}/organizationImportTemplate +func (c *BackendOrganizationController) GetImportTemplate() { + if _, ok := c.tenantID(); !ok { + return + } + + c.Ctx.Output.Header("Content-Type", "text/csv; charset=utf-8") + c.Ctx.Output.Header("Content-Disposition", "attachment; filename=organization_template.csv") + + _, _ = c.Ctx.ResponseWriter.Write([]byte{0xEF, 0xBB, 0xBF}) + writer := csv.NewWriter(c.Ctx.ResponseWriter) + _ = writer.Write(organizationExportHeader) + _ = writer.Write([]string{"COM001", "示例总公司", "", "1", "0", "1", "顶级组织,上级编码留空"}) + _ = writer.Write([]string{"DEP001", "示例研发部", "COM001", "0", "1", "1", "隶属 COM001"}) + writer.Flush() +} + +// bomTrimReader 去掉 CSV 文件开头可能存在的 UTF-8 BOM。 +type bomTrimReader struct { + reader io.Reader + checked bool + buf []byte +} + +func newBOMTrimReader(r io.Reader) io.Reader { + return &bomTrimReader{reader: r} +} + +func (r *bomTrimReader) Read(p []byte) (int, error) { + if !r.checked { + r.checked = true + head := make([]byte, 3) + n, err := io.ReadFull(r.reader, head) + if n == 3 && head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF { + r.buf = nil + } else { + r.buf = head[:n] + } + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return 0, err + } + } + if len(r.buf) > 0 { + n := copy(p, r.buf) + r.buf = r.buf[n:] + return n, nil + } + return r.reader.Read(p) +} + +func csvField(record []string, index int) string { + if index >= len(record) { + return "" + } + return strings.TrimSpace(record[index]) +} + +func csvInt(record []string, index int, fallback int) int { + raw := csvField(record, index) + if raw == "" { + return fallback + } + v, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return v +} diff --git a/go/controllers/backend_organization_test.go b/go/controllers/backend_organization_test.go new file mode 100644 index 0000000..534c4a4 --- /dev/null +++ b/go/controllers/backend_organization_test.go @@ -0,0 +1,185 @@ +package controllers + +import ( + "io" + "strings" + "testing" +) + +// 这些用例只覆盖组织架构模块中不依赖数据库的纯函数:树组装、层级计算、CSV 解析与编码生成。 + +func TestBuildOrganizationTree(t *testing.T) { + list := []organizationDTO{ + {ID: 1, OrgName: "总公司", ParentID: 0}, + {ID: 2, OrgName: "研发部", ParentID: 1}, + {ID: 3, OrgName: "前端组", ParentID: 2}, + {ID: 4, OrgName: "孤儿部门", ParentID: 999}, // 上级不在列表中,应作为根节点保留 + } + + tree := buildOrganizationTree(list) + if len(tree) != 2 { + t.Fatalf("期望 2 个根节点,实际 %d", len(tree)) + } + + root := tree[0] + if root["org_name"] != "总公司" { + t.Fatalf("第一个根节点应为总公司,实际 %v", root["org_name"]) + } + + children, ok := root["children"].([]map[string]interface{}) + if !ok || len(children) != 1 { + t.Fatalf("总公司应有 1 个子节点,实际 %v", root["children"]) + } + grandChildren, ok := children[0]["children"].([]map[string]interface{}) + if !ok || len(grandChildren) != 1 || grandChildren[0]["org_name"] != "前端组" { + t.Fatalf("研发部下应有前端组,实际 %v", children[0]["children"]) + } + + if tree[1]["org_name"] != "孤儿部门" { + t.Fatalf("上级缺失的节点应作为根节点保留,实际 %v", tree[1]["org_name"]) + } +} + +func TestTreeDepth(t *testing.T) { + childrenOf := map[uint64][]uint64{ + 0: {1}, + 1: {2, 3}, + 2: {4}, + } + if got := treeDepth(childrenOf, 0, 0); got != 3 { + t.Fatalf("期望深度 3,实际 %d", got) + } + if got := treeDepth(map[uint64][]uint64{}, 0, 0); got != 0 { + t.Fatalf("空树深度应为 0,实际 %d", got) + } +} + +func TestSubtreeHeightFrom(t *testing.T) { + childrenOf := map[uint64][]uint64{ + 1: {2, 3}, + 2: {4}, + } + if got := subtreeHeightFrom(childrenOf, 1, 0); got != 3 { + t.Fatalf("以 1 为根的子树高度应为 3,实际 %d", got) + } + if got := subtreeHeightFrom(childrenOf, 4, 0); got != 1 { + t.Fatalf("叶子节点高度应为 1,实际 %d", got) + } +} + +func TestCSVHelpers(t *testing.T) { + record := []string{" COM001 ", "总公司", "", "1"} + + if got := csvField(record, 0); got != "COM001" { + t.Fatalf("csvField 应去掉空格,实际 %q", got) + } + if got := csvField(record, 9); got != "" { + t.Fatalf("越界应返回空串,实际 %q", got) + } + if got := csvInt(record, 3, 0); got != 1 { + t.Fatalf("csvInt 应解析出 1,实际 %d", got) + } + if got := csvInt(record, 2, 7); got != 7 { + t.Fatalf("空值应返回默认值 7,实际 %d", got) + } + if got := csvInt(record, 1, 5); got != 5 { + t.Fatalf("非数字应返回默认值 5,实际 %d", got) + } +} + +func TestBOMTrimReader(t *testing.T) { + withBOM := append([]byte{0xEF, 0xBB, 0xBF}, []byte("编码,名称\n")...) + got, err := io.ReadAll(newBOMTrimReader(strings.NewReader(string(withBOM)))) + if err != nil { + t.Fatalf("读取失败: %v", err) + } + if string(got) != "编码,名称\n" { + t.Fatalf("BOM 未被去掉,实际 %q", string(got)) + } + + got, err = io.ReadAll(newBOMTrimReader(strings.NewReader("编码,名称\n"))) + if err != nil { + t.Fatalf("读取失败: %v", err) + } + if string(got) != "编码,名称\n" { + t.Fatalf("无 BOM 时内容被破坏,实际 %q", string(got)) + } + + // 内容短于 3 字节时不能丢数据 + got, err = io.ReadAll(newBOMTrimReader(strings.NewReader("ab"))) + if err != nil { + t.Fatalf("读取失败: %v", err) + } + if string(got) != "ab" { + t.Fatalf("短内容被破坏,实际 %q", string(got)) + } +} + +func TestGenerateCode(t *testing.T) { + c := &BackendOrganizationController{} + + code := c.generateCode("ORG", 8) + if len(code) != 8 || !strings.HasPrefix(code, "ORG") { + t.Fatalf("期望 8 位且以 ORG 开头,实际 %q", code) + } + + // 长度限制小于前缀长度时不截断前缀 + code = c.generateCode("PREFIX", 3) + if !strings.HasPrefix(code, "PREFIX") { + t.Fatalf("前缀不应被截断,实际 %q", code) + } + + // 不限制长度时返回完整前缀 + 时间戳 + code = c.generateCode("EMP", 0) + if len(code) != len("EMP")+14 { + t.Fatalf("未限制长度时应为前缀加 14 位时间戳,实际 %q", code) + } +} + +func TestUniqueMessageAndClamp(t *testing.T) { + if got := uniqueMessage(true, "编码"); got != "编码可用" { + t.Fatalf("实际 %q", got) + } + if got := uniqueMessage(false, "账号"); got != "账号已存在" { + t.Fatalf("实际 %q", got) + } + if got := clampInt(0, 4, 32); got != 4 { + t.Fatalf("下界钳制失败,实际 %d", got) + } + if got := clampInt(99, 4, 32); got != 32 { + t.Fatalf("上界钳制失败,实际 %d", got) + } + if got := clampInt(10, 4, 32); got != 10 { + t.Fatalf("区间内不应改变,实际 %d", got) + } +} + +func TestHashEmployeePassword(t *testing.T) { + if got := hashEmployeePassword(" "); got != "" { + t.Fatalf("空密码应返回空串,实际 %q", got) + } + got := hashEmployeePassword("secret123") + if len(got) != 64 { + t.Fatalf("sha256 hex 应为 64 位,实际 %d", len(got)) + } + if got != hashEmployeePassword("secret123") { + t.Fatal("相同输入应得到相同结果") + } +} + +func TestOrgNameByIDString(t *testing.T) { + nameByID := map[uint64]string{7: "研发部"} + + if got := orgNameByIDString(nameByID, "7"); got != "研发部" { + t.Fatalf("实际 %q", got) + } + if got := orgNameByIDString(nameByID, ""); got != "" { + t.Fatalf("空值应返回空串,实际 %q", got) + } + if got := orgNameByIDString(nameByID, "abc"); got != "" { + t.Fatalf("非数字应返回空串,实际 %q", got) + } + if got := orgNameByIDString(nameByID, "99"); got != "" { + t.Fatalf("未命中应返回空串,实际 %q", got) + } +} diff --git a/go/models/erp.go b/go/models/backend_organization.go similarity index 61% rename from go/models/erp.go rename to go/models/backend_organization.go index fcc2489..fa5d44c 100644 --- a/go/models/erp.go +++ b/go/models/backend_organization.go @@ -1,73 +1,78 @@ -package models - -import "time" - -// BackendErpOrganization 组织架构表 yz_backend_erp_organization -type BackendErpOrganization struct { - ID uint64 `orm:"column(id);pk;auto" json:"id"` - Tid uint64 `orm:"column(tid)" json:"tid"` - OrgName string `orm:"column(org_name);size(128)" json:"org_name"` - OrgCode string `orm:"column(org_code);size(64)" json:"org_code"` - ParentID uint64 `orm:"column(parent_id);default(0)" json:"parent_id"` - Sort uint `orm:"column(sort);default(0)" json:"sort"` - LeaderID *uint64 `orm:"column(leader_id);null" json:"leader_id"` - IsCompany int `orm:"column(is_company);default(0)" json:"is_company"` - Status int8 `orm:"column(status);default(1)" json:"status"` - CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` - UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` - DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` - Remark *string `orm:"column(remark);size(512);null" json:"remark"` -} - -// TableName 自定义表名 -func (m *BackendErpOrganization) TableName() string { - return "yz_backend_erp_organization" -} - -// BackendErpEmployee 员工信息表 yz_backend_erp_employee -type BackendErpEmployee struct { - ID uint `orm:"column(id);pk;auto" json:"id"` - Tid *int `orm:"column(tid);null" json:"tid"` - Account string `orm:"column(account);size(50)" json:"account"` - Password string `orm:"column(password);size(64);default()" json:"-"` - Name string `orm:"column(name);size(30)" json:"name"` - Gender int8 `orm:"column(gender);default(0)" json:"gender"` - Birthday *time.Time `orm:"column(birthday);type(date);null" json:"birthday"` - AffiliateUnit *string `orm:"column(affiliate_unit);size(100);null" json:"affiliate_unit"` - Department *string `orm:"column(department);size(50);null" json:"department"` - Position *string `orm:"column(position);size(50);null" json:"position"` - Education *string `orm:"column(education);size(20);null" json:"education"` - Nation *string `orm:"column(nation);size(20);null" json:"nation"` - Phone *string `orm:"column(phone);size(20);null" json:"phone"` - Wechat *string `orm:"column(wechat);size(50);null" json:"wechat"` - Email *string `orm:"column(email);size(100);null" json:"email"` - HomeAddress *string `orm:"column(home_address);size(255);null" json:"home_address"` - AccountStatus int8 `orm:"column(account_status);default(1)" json:"account_status"` - CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` - UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` - DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` -} - -// TableName 自定义表名 -func (m *BackendErpEmployee) TableName() string { - return "yz_backend_erp_employee" -} - -// BackendErpPosition 职位表 yz_backend_erp_position -type BackendErpPosition struct { - ID uint64 `orm:"column(id);pk;auto" json:"id"` - Tid uint64 `orm:"column(tid)" json:"tid"` - DepartmentID uint64 `orm:"column(department_id)" json:"department_id"` - PositionCode string `orm:"column(position_code);size(50)" json:"position_code"` - PositionName string `orm:"column(position_name);size(100)" json:"position_name"` - PositionType int8 `orm:"column(position_type);default(0)" json:"position_type"` - Status int8 `orm:"column(status);default(1)" json:"status"` - Sort uint `orm:"column(sort);default(0)" json:"sort"` - CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` - UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` -} - -// TableName 自定义表名 -func (m *BackendErpPosition) TableName() string { - return "yz_backend_erp_position" -} +package models + +import "time" + +// 组织架构(组织 / 员工 / 职位)为租户端通用基础数据,进销存(ERP)与办公自动化(OA)共用同一份数据, +// 通过 tid(租户ID)实现租户之间的数据隔离。表名统一使用 yz_backend_ 前缀。 + +// BackendOrganization 组织架构表 yz_backend_organization +type BackendOrganization struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid)" json:"tid"` + OrgName string `orm:"column(org_name);size(128)" json:"org_name"` + OrgCode string `orm:"column(org_code);size(64)" json:"org_code"` + ParentID uint64 `orm:"column(parent_id);default(0)" json:"parent_id"` + Sort uint `orm:"column(sort);default(0)" json:"sort"` + LeaderID *uint64 `orm:"column(leader_id);null" json:"leader_id"` + IsCompany int `orm:"column(is_company);default(0)" json:"is_company"` + Status int8 `orm:"column(status);default(1)" json:"status"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` + Remark *string `orm:"column(remark);size(512);null" json:"remark"` +} + +// TableName 自定义表名 +func (m *BackendOrganization) TableName() string { + return "yz_backend_organization" +} + +// BackendEmployee 员工信息表 yz_backend_employee +type BackendEmployee struct { + ID uint `orm:"column(id);pk;auto" json:"id"` + Tid *int `orm:"column(tid);null" json:"tid"` + Account string `orm:"column(account);size(50)" json:"account"` + Password string `orm:"column(password);size(64);default()" json:"-"` + Name string `orm:"column(name);size(30)" json:"name"` + Gender int8 `orm:"column(gender);default(0)" json:"gender"` + Birthday *time.Time `orm:"column(birthday);type(date);null" json:"birthday"` + AffiliateUnit *string `orm:"column(affiliate_unit);size(100);null" json:"affiliate_unit"` + Department *string `orm:"column(department);size(50);null" json:"department"` + Position *string `orm:"column(position);size(50);null" json:"position"` + Education *string `orm:"column(education);size(20);null" json:"education"` + Nation *string `orm:"column(nation);size(20);null" json:"nation"` + Phone *string `orm:"column(phone);size(20);null" json:"phone"` + Wechat *string `orm:"column(wechat);size(50);null" json:"wechat"` + Email *string `orm:"column(email);size(100);null" json:"email"` + HomeAddress *string `orm:"column(home_address);size(255);null" json:"home_address"` + AccountStatus int8 `orm:"column(account_status);default(1)" json:"account_status"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +// TableName 自定义表名 +func (m *BackendEmployee) TableName() string { + return "yz_backend_employee" +} + +// BackendPosition 职位表 yz_backend_position +type BackendPosition struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid)" json:"tid"` + DepartmentID uint64 `orm:"column(department_id)" json:"department_id"` + PositionCode string `orm:"column(position_code);size(50)" json:"position_code"` + PositionName string `orm:"column(position_name);size(100)" json:"position_name"` + PositionType int8 `orm:"column(position_type);default(0)" json:"position_type"` + Status int8 `orm:"column(status);default(1)" json:"status"` + Sort uint `orm:"column(sort);default(0)" json:"sort"` + Remark *string `orm:"column(remark);size(512);null" json:"remark"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +// TableName 自定义表名 +func (m *BackendPosition) TableName() string { + return "yz_backend_position" +} diff --git a/go/models/init.go b/go/models/init.go index 424e053..65edfd2 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -35,9 +35,9 @@ func Init(_ string) { orm.RegisterModel( new(SystemTenant), new(SystemTenantUser), - new(BackendErpOrganization), - new(BackendErpEmployee), - new(BackendErpPosition), + new(BackendOrganization), + new(BackendEmployee), + new(BackendPosition), new(BackendApprovalFlow), new(BackendApprovalRecord), new(BackendReimbursement), diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index b70a68a..334cd30 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -96,35 +96,11 @@ func RegisterAuthRoutes() { beego.Router("/backend/deleteUser/:id", &controllers.BackendAdminUserController{}, "delete:DeleteUser") beego.Router("/backend/changePassword", &controllers.BackendAdminUserController{}, "post:ChangePassword") - // ERP 接口 - beego.Router("/backend/erp/getOrganization", &controllers.BackendErpController{}, "get:GetOrganization") - beego.Router("/backend/erp/getOrganizationDetail/:id", &controllers.BackendErpController{}, "get:GetOrganizationDetail") - beego.Router("/backend/erp/createOrganization", &controllers.BackendErpController{}, "post:CreateOrganization") - beego.Router("/backend/erp/editOrganization/:id", &controllers.BackendErpController{}, "post:EditOrganization") - beego.Router("/backend/erp/deleteOrganization/:id", &controllers.BackendErpController{}, "delete:DeleteOrganization") - beego.Router("/backend/erp/getCompanys", &controllers.BackendErpController{}, "get:GetCompanys") - beego.Router("/backend/erp/getDepartments", &controllers.BackendErpController{}, "get:GetDepartments") - beego.Router("/backend/erp/getEmployee", &controllers.BackendErpController{}, "get:GetEmployee") - beego.Router("/backend/erp/getEmployeeDetail/:id", &controllers.BackendErpController{}, "get:GetEmployeeDetail") - beego.Router("/backend/erp/createEmployee", &controllers.BackendErpController{}, "post:CreateEmployee") - beego.Router("/backend/erp/editEmployee/:id", &controllers.BackendErpController{}, "post:EditEmployee") - beego.Router("/backend/erp/deleteEmployee/:id", &controllers.BackendErpController{}, "delete:DeleteEmployee") - beego.Router("/backend/erp/getPosition", &controllers.BackendErpController{}, "get:GetPosition") - beego.Router("/backend/erp/getPositionDetail/:id", &controllers.BackendErpController{}, "get:GetPositionDetail") - beego.Router("/backend/erp/createPosition", &controllers.BackendErpController{}, "post:CreatePosition") - beego.Router("/backend/erp/editPosition/:id", &controllers.BackendErpController{}, "post:EditPosition") - beego.Router("/backend/erp/deletePosition/:id", &controllers.BackendErpController{}, "delete:DeletePosition") - - // 新增组织架构接口 - beego.Router("/backend/erp/getOrgSettings", &controllers.BackendErpController{}, "get:GetOrgSettings") - beego.Router("/backend/erp/saveOrgSettings", &controllers.BackendErpController{}, "post:SaveOrgSettings") - beego.Router("/backend/erp/getOrganizationTree", &controllers.BackendErpController{}, "get:GetOrganizationTree") - beego.Router("/backend/erp/searchOrganizations", &controllers.BackendErpController{}, "get:SearchOrganizations") - beego.Router("/backend/erp/getOrganizationEmployees/:org_id", &controllers.BackendErpController{}, "get:GetOrganizationEmployees") - beego.Router("/backend/erp/moveEmployeeToOrg", &controllers.BackendErpController{}, "post:MoveEmployeeToOrg") - beego.Router("/backend/erp/getOrganizationHierarchy/:org_id", &controllers.BackendErpController{}, "get:GetOrganizationHierarchy") - beego.Router("/backend/erp/checkOrgCodeUnique", &controllers.BackendErpController{}, "get:CheckOrgCodeUnique") - beego.Router("/backend/erp/checkEmployeeAccountUnique", &controllers.BackendErpController{}, "get:CheckEmployeeAccountUnique") + // 组织架构(组织 / 员工 / 职位) + // 组织架构是租户端通用基础数据:进销存(erp)与办公自动化(oa)两套界面读写同一份数据, + // 数据按 JWT 中的租户ID 隔离。两个前缀注册到同一个控制器,便于两端各自的菜单与权限配置。 + registerOrganizationRoutes("erp") + registerOrganizationRoutes("oa") // 文章管理 beego.Router("/backend/articlesList", &controllers.BackendArticleController{}, "get:List") @@ -242,3 +218,51 @@ func RegisterAuthRoutes() { beego.Router("/backend/reminder/batchDelete", &controllers.BackendReminderController{}, "post:BatchDeleteReminder") beego.Router("/backend/reminder/finish/:id", &controllers.BackendReminderController{}, "post:FinishReminder") } + +// registerOrganizationRoutes 为指定模块前缀注册组织架构路由。 +// 进销存(erp)与办公自动化(oa)各有独立界面,但共用 BackendOrganizationController 与同一套表, +// 因此这里用同一份定义注册两遍,只有 URL 前缀不同。 +func registerOrganizationRoutes(module string) { + prefix := "/backend/" + module + + // 组织机构 + beego.Router(prefix+"/getOrganization", &controllers.BackendOrganizationController{}, "get:GetOrganization") + beego.Router(prefix+"/getOrganizationTree", &controllers.BackendOrganizationController{}, "get:GetOrganizationTree") + beego.Router(prefix+"/getOrganizationDetail/:id", &controllers.BackendOrganizationController{}, "get:GetOrganizationDetail") + beego.Router(prefix+"/createOrganization", &controllers.BackendOrganizationController{}, "post:CreateOrganization") + beego.Router(prefix+"/editOrganization/:id", &controllers.BackendOrganizationController{}, "post:EditOrganization") + beego.Router(prefix+"/deleteOrganization/:id", &controllers.BackendOrganizationController{}, "delete:DeleteOrganization") + beego.Router(prefix+"/getCompanys", &controllers.BackendOrganizationController{}, "get:GetCompanys") + beego.Router(prefix+"/getDepartments", &controllers.BackendOrganizationController{}, "get:GetDepartments") + beego.Router(prefix+"/searchOrganizations", &controllers.BackendOrganizationController{}, "get:SearchOrganizations") + beego.Router(prefix+"/getOrganizationHierarchy/:org_id", &controllers.BackendOrganizationController{}, "get:GetOrganizationHierarchy") + beego.Router(prefix+"/getOrganizationStats", &controllers.BackendOrganizationController{}, "get:GetOrganizationStats") + beego.Router(prefix+"/moveOrganization", &controllers.BackendOrganizationController{}, "post:MoveOrganization") + beego.Router(prefix+"/batchOrganizeOrganizations", &controllers.BackendOrganizationController{}, "post:BatchOrganizeOrganizations") + beego.Router(prefix+"/checkOrgCodeUnique", &controllers.BackendOrganizationController{}, "get:CheckOrgCodeUnique") + + // 员工 + beego.Router(prefix+"/getEmployee", &controllers.BackendOrganizationController{}, "get:GetEmployee") + beego.Router(prefix+"/getEmployeeDetail/:id", &controllers.BackendOrganizationController{}, "get:GetEmployeeDetail") + beego.Router(prefix+"/createEmployee", &controllers.BackendOrganizationController{}, "post:CreateEmployee") + beego.Router(prefix+"/editEmployee/:id", &controllers.BackendOrganizationController{}, "post:EditEmployee") + beego.Router(prefix+"/deleteEmployee/:id", &controllers.BackendOrganizationController{}, "delete:DeleteEmployee") + beego.Router(prefix+"/getOrganizationEmployees/:org_id", &controllers.BackendOrganizationController{}, "get:GetOrganizationEmployees") + beego.Router(prefix+"/moveEmployeeToOrg", &controllers.BackendOrganizationController{}, "post:MoveEmployeeToOrg") + beego.Router(prefix+"/checkEmployeeAccountUnique", &controllers.BackendOrganizationController{}, "get:CheckEmployeeAccountUnique") + + // 职位 + beego.Router(prefix+"/getPosition", &controllers.BackendOrganizationController{}, "get:GetPosition") + beego.Router(prefix+"/getPositionDetail/:id", &controllers.BackendOrganizationController{}, "get:GetPositionDetail") + beego.Router(prefix+"/createPosition", &controllers.BackendOrganizationController{}, "post:CreatePosition") + beego.Router(prefix+"/editPosition/:id", &controllers.BackendOrganizationController{}, "post:EditPosition") + beego.Router(prefix+"/deletePosition/:id", &controllers.BackendOrganizationController{}, "delete:DeletePosition") + beego.Router(prefix+"/checkPositionCodeUnique", &controllers.BackendOrganizationController{}, "get:CheckPositionCodeUnique") + + // 设置与导入导出 + beego.Router(prefix+"/getOrgSettings", &controllers.BackendOrganizationController{}, "get:GetOrgSettings") + beego.Router(prefix+"/saveOrgSettings", &controllers.BackendOrganizationController{}, "post:SaveOrgSettings") + beego.Router(prefix+"/exportOrganization", &controllers.BackendOrganizationController{}, "get:ExportOrganization") + beego.Router(prefix+"/importOrganization", &controllers.BackendOrganizationController{}, "post:ImportOrganization") + beego.Router(prefix+"/organizationImportTemplate", &controllers.BackendOrganizationController{}, "get:GetImportTemplate") +} diff --git a/go/routers/backend/backend_test.go b/go/routers/backend/backend_test.go new file mode 100644 index 0000000..5fac58b --- /dev/null +++ b/go/routers/backend/backend_test.go @@ -0,0 +1,16 @@ +package backend + +import "testing" + +// TestRegisterOrganizationRoutes 确认组织架构路由能为 erp 与 oa 两个前缀完成注册且不冲突。 +// beego 在注册重复路由或非法方法名时会 panic,这里通过实际注册来兜住这类错误。 +func TestRegisterOrganizationRoutes(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("注册组织架构路由失败: %v", r) + } + }() + + registerOrganizationRoutes("erp") + registerOrganizationRoutes("oa") +} diff --git a/sql/rename_erp_tables_to_backend_organization.sql b/sql/rename_erp_tables_to_backend_organization.sql new file mode 100644 index 0000000..60f72d0 --- /dev/null +++ b/sql/rename_erp_tables_to_backend_organization.sql @@ -0,0 +1,193 @@ +-- 组织架构模块:表名去掉 erp 字样,改为租户端通用命名 +-- yz_backend_erp_organization -> yz_backend_organization +-- yz_backend_erp_employee -> yz_backend_employee +-- yz_backend_erp_position -> yz_backend_position +-- +-- 组织架构由进销存(ERP)与办公自动化(OA)共用同一份数据,通过 tid 实现租户间隔离。 +-- 执行前请确认当前数据库字符集为 utf8mb4,并先备份这三张表。 +-- +-- 说明:脚本可重复执行。如果旧表不存在(全新环境),RENAME 会被跳过, +-- 后面的 CREATE TABLE IF NOT EXISTS 会建出目标表结构。 + +-- --------------------------------------------------------------------------- +-- 1. 重命名已有表(仅当旧表存在且新表不存在时执行) +-- --------------------------------------------------------------------------- + +SET @schema := DATABASE(); + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_erp_organization') = 1 + AND (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_organization') = 0, + 'RENAME TABLE `yz_backend_erp_organization` TO `yz_backend_organization`', + 'SELECT "skip: yz_backend_organization"' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_erp_employee') = 1 + AND (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_employee') = 0, + 'RENAME TABLE `yz_backend_erp_employee` TO `yz_backend_employee`', + 'SELECT "skip: yz_backend_employee"' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_erp_position') = 1 + AND (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_position') = 0, + 'RENAME TABLE `yz_backend_erp_position` TO `yz_backend_position`', + 'SELECT "skip: yz_backend_position"' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- --------------------------------------------------------------------------- +-- 2. 全新环境建表(旧表不存在时生效) +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS `yz_backend_organization` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `tid` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '租户ID(数据隔离)', + `org_name` varchar(128) NOT NULL DEFAULT '' COMMENT '组织名称', + `org_code` varchar(64) NOT NULL DEFAULT '' COMMENT '组织编码(租户内唯一)', + `parent_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '上级组织ID,0为顶级', + `sort` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '排序,越小越靠前', + `leader_id` bigint(20) unsigned DEFAULT NULL COMMENT '负责人(员工ID)', + `is_company` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否企业单位:1-是 0-部门', + `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1-启用 0-禁用', + `remark` varchar(512) DEFAULT NULL COMMENT '备注', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_tid` (`tid`) USING BTREE, + KEY `idx_tid_code` (`tid`,`org_code`) USING BTREE, + KEY `idx_parent_id` (`parent_id`) USING BTREE, + KEY `idx_is_company` (`is_company`) USING BTREE, + KEY `idx_status` (`status`) USING BTREE, + KEY `idx_delete_time` (`delete_time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='组织架构表(ERP与OA共用)'; + +CREATE TABLE IF NOT EXISTS `yz_backend_employee` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `tid` int(11) DEFAULT NULL COMMENT '租户ID(数据隔离)', + `account` varchar(50) NOT NULL DEFAULT '' COMMENT '登录账号(租户内唯一)', + `password` varchar(64) NOT NULL DEFAULT '' COMMENT '密码(sha256 hex)', + `name` varchar(30) NOT NULL DEFAULT '' COMMENT '姓名', + `gender` tinyint(1) NOT NULL DEFAULT '0' COMMENT '性别:0-未知 1-男 2-女', + `birthday` date DEFAULT NULL COMMENT '生日', + `affiliate_unit` varchar(100) DEFAULT NULL COMMENT '隶属单位(组织ID)', + `department` varchar(50) DEFAULT NULL COMMENT '部门(组织ID)', + `position` varchar(50) DEFAULT NULL COMMENT '职位', + `education` varchar(20) DEFAULT NULL COMMENT '学历', + `nation` varchar(20) DEFAULT NULL COMMENT '民族', + `phone` varchar(20) DEFAULT NULL COMMENT '手机号', + `wechat` varchar(50) DEFAULT NULL COMMENT '微信', + `email` varchar(100) DEFAULT NULL COMMENT '邮箱', + `home_address` varchar(255) DEFAULT NULL COMMENT '家庭住址', + `account_status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '账号状态:1-启用 0-禁用 2-离职', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_tid` (`tid`) USING BTREE, + KEY `idx_tid_account` (`tid`,`account`) USING BTREE, + KEY `idx_department` (`department`) USING BTREE, + KEY `idx_affiliate_unit` (`affiliate_unit`) USING BTREE, + KEY `idx_account_status` (`account_status`) USING BTREE, + KEY `idx_delete_time` (`delete_time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='员工信息表(ERP与OA共用)'; + +CREATE TABLE IF NOT EXISTS `yz_backend_position` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `tid` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '租户ID(数据隔离)', + `department_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '所属部门(组织ID)', + `position_code` varchar(50) NOT NULL DEFAULT '' COMMENT '职位编码(租户内唯一)', + `position_name` varchar(100) NOT NULL DEFAULT '' COMMENT '职位名称', + `position_type` tinyint(1) NOT NULL DEFAULT '0' COMMENT '职位类型:0-普通 1-主管 2-负责人', + `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1-启用 0-禁用', + `sort` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '排序,越小越靠前', + `remark` varchar(512) DEFAULT NULL COMMENT '备注', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_tid` (`tid`) USING BTREE, + KEY `idx_tid_code` (`tid`,`position_code`) USING BTREE, + KEY `idx_department_id` (`department_id`) USING BTREE, + KEY `idx_status` (`status`) USING BTREE, + KEY `idx_delete_time` (`delete_time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='职位表(ERP与OA共用)'; + +-- --------------------------------------------------------------------------- +-- 3. 补齐旧表缺失的字段与索引(重命名后的表可能来自更早的版本) +-- --------------------------------------------------------------------------- + +-- yz_backend_position 新增 remark / delete_time +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_position' + AND COLUMN_NAME = 'remark') = 0, + 'ALTER TABLE `yz_backend_position` ADD COLUMN `remark` varchar(512) DEFAULT NULL COMMENT ''备注'' AFTER `sort`', + 'SELECT "skip: yz_backend_position.remark"' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_position' + AND COLUMN_NAME = 'delete_time') = 0, + 'ALTER TABLE `yz_backend_position` ADD COLUMN `delete_time` datetime DEFAULT NULL COMMENT ''删除时间(软删除)''', + 'SELECT "skip: yz_backend_position.delete_time"' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_position' + AND INDEX_NAME = 'idx_tid_code') = 0, + 'ALTER TABLE `yz_backend_position` ADD INDEX `idx_tid_code` (`tid`,`position_code`) USING BTREE', + 'SELECT "skip: yz_backend_position.idx_tid_code"' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_organization' + AND INDEX_NAME = 'idx_tid_code') = 0, + 'ALTER TABLE `yz_backend_organization` ADD INDEX `idx_tid_code` (`tid`,`org_code`) USING BTREE', + 'SELECT "skip: yz_backend_organization.idx_tid_code"' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = 'yz_backend_employee' + AND INDEX_NAME = 'idx_tid_account') = 0, + 'ALTER TABLE `yz_backend_employee` ADD INDEX `idx_tid_account` (`tid`,`account`) USING BTREE', + 'SELECT "skip: yz_backend_employee.idx_tid_account"' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- --------------------------------------------------------------------------- +-- 4. 组织架构设置存放在 yz_backend_normal_setting,按 backend_org_settings_{tid} 区分租户 +-- 这里只保证该表存在,具体记录由后端首次保存设置时写入。 +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS `yz_backend_normal_setting` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` varchar(128) NOT NULL DEFAULT '' COMMENT '配置名称', + `value` text COMMENT '配置内容(JSON)', + `code` varchar(64) NOT NULL DEFAULT '' COMMENT '配置标识', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_code` (`code`) USING BTREE, + KEY `idx_delete_time` (`delete_time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='管理端通用配置表'; diff --git a/sql/seed_organization_menus.sql b/sql/seed_organization_menus.sql new file mode 100644 index 0000000..3b2aef0 --- /dev/null +++ b/sql/seed_organization_menus.sql @@ -0,0 +1,96 @@ +-- 组织架构菜单(租户端):进销存与办公自动化各自一套入口,指向共用页面组件。 +-- +-- 前置:yz_system_menu 中已存在 path 为 /apps/erp 与 /apps/oa 的模块目录菜单。 +-- 若不存在,脚本会自动创建这两个目录菜单。 +-- views 字段:[1] 平台端 / [2] 租户端 / [1,2] 双端。组织架构属于租户端,固定为 [2]。 +-- type 字段:1-目录 2-页面。 +-- +-- 脚本可重复执行:按 path 判重,已存在的菜单会更新组件路径与标题。 + +-- --------------------------------------------------------------------------- +-- 1. 确保模块目录存在 +-- --------------------------------------------------------------------------- + +INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`) +SELECT 0, '进销存', '/apps/erp', '', 'Goods', 30, 1, 1, '[2]', 1, '进销存模块' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/erp' +); + +INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`) +SELECT 0, '办公自动化', '/apps/oa', '', 'Document', 31, 1, 1, '[2]', 1, '办公自动化模块' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/oa' +); + +SET @erp_pid := (SELECT `id` FROM `yz_system_menu` WHERE `path` = '/apps/erp' ORDER BY `id` LIMIT 1); +SET @oa_pid := (SELECT `id` FROM `yz_system_menu` WHERE `path` = '/apps/oa' ORDER BY `id` LIMIT 1); + +-- --------------------------------------------------------------------------- +-- 2. 进销存:组织架构 / 员工管理 / 职位管理 +-- --------------------------------------------------------------------------- + +INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`) +SELECT @erp_pid, '组织架构', '/apps/erp/organization', '/apps/erp/organization/index.vue', 'OfficeBuilding', 1, 1, 1, '[2]', 2, '与OA共用组织数据' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/erp/organization' +); + +INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`) +SELECT @erp_pid, '员工管理', '/apps/erp/employee', '/apps/erp/employee/index.vue', 'User', 2, 1, 1, '[2]', 2, '与OA共用员工数据' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/erp/employee' +); + +INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`) +SELECT @erp_pid, '职位管理', '/apps/erp/position', '/apps/erp/position/index.vue', 'Postcard', 3, 1, 1, '[2]', 2, '与OA共用职位数据' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/erp/position' +); + +-- --------------------------------------------------------------------------- +-- 3. 办公自动化:组织架构 / 人员管理 / 职位管理 +-- --------------------------------------------------------------------------- + +INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`) +SELECT @oa_pid, '组织架构', '/apps/oa/organization', '/apps/oa/organization/index.vue', 'OfficeBuilding', 1, 1, 1, '[2]', 2, '与进销存共用组织数据' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/oa/organization' +); + +INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`) +SELECT @oa_pid, '人员管理', '/apps/oa/employee', '/apps/oa/employee/index.vue', 'User', 2, 1, 1, '[2]', 2, '与进销存共用员工数据' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/oa/employee' +); + +INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`) +SELECT @oa_pid, '职位管理', '/apps/oa/position', '/apps/oa/position/index.vue', 'Postcard', 3, 1, 1, '[2]', 2, '与进销存共用职位数据' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/oa/position' +); + +-- --------------------------------------------------------------------------- +-- 4. 修正已有菜单的组件路径(历史数据可能指向旧组件) +-- --------------------------------------------------------------------------- + +UPDATE `yz_system_menu` SET `component_path` = '/apps/erp/organization/index.vue', `type` = 2, `views` = '[2]' + WHERE `path` = '/apps/erp/organization'; +UPDATE `yz_system_menu` SET `component_path` = '/apps/erp/employee/index.vue', `type` = 2, `views` = '[2]' + WHERE `path` = '/apps/erp/employee'; +UPDATE `yz_system_menu` SET `component_path` = '/apps/erp/position/index.vue', `type` = 2, `views` = '[2]' + WHERE `path` = '/apps/erp/position'; +UPDATE `yz_system_menu` SET `component_path` = '/apps/oa/organization/index.vue', `type` = 2, `views` = '[2]' + WHERE `path` = '/apps/oa/organization'; +UPDATE `yz_system_menu` SET `component_path` = '/apps/oa/employee/index.vue', `type` = 2, `views` = '[2]' + WHERE `path` = '/apps/oa/employee'; +UPDATE `yz_system_menu` SET `component_path` = '/apps/oa/position/index.vue', `type` = 2, `views` = '[2]' + WHERE `path` = '/apps/oa/position';